From 31ab424d9de0e4ff613949321fbffcfb11b168a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20P=C3=A9rex?= Date: Mon, 7 Jul 2025 23:10:30 +0100 Subject: [PATCH] chore: add main directories --- migrations/001_initial_setup.sql | 504 ++++++++++ migrations/001_initial_setup_postgres.sql | 100 ++ migrations/001_initial_setup_sqlite.sql | 96 ++ migrations/002_add_2fa_support.sql | 101 ++ migrations/002_add_2fa_support_postgres.sql | 131 +++ migrations/002_add_2fa_support_sqlite.sql | 117 +++ migrations/003_rbac_system_postgres.sql | 320 +++++++ migrations/README.md | 316 +++++++ migrations/migration_files.md | 111 +++ .../grafana/dashboards/rustelo-overview.json | 886 ++++++++++++++++++ .../provisioning/dashboards/dashboards.yml | 42 + .../provisioning/datasources/datasources.yml | 37 + monitoring/prometheus.yml | 67 ++ public/favicon.ico | Bin 0 -> 15406 bytes public/logos/rustelo-imag.svg | 289 ++++++ public/logos/rustelo_dev-logo-b-h.svg | 307 ++++++ public/logos/rustelo_dev-logo-b-v.svg | 307 ++++++ public/logos/rustelo_dev-logo-h.svg | 307 ++++++ public/logos/rustelo_dev-logo-v.svg | 307 ++++++ public/website.css | 2 + style/main.scss | 9 + 21 files changed, 4356 insertions(+) create mode 100644 migrations/001_initial_setup.sql create mode 100644 migrations/001_initial_setup_postgres.sql create mode 100644 migrations/001_initial_setup_sqlite.sql create mode 100644 migrations/002_add_2fa_support.sql create mode 100644 migrations/002_add_2fa_support_postgres.sql create mode 100644 migrations/002_add_2fa_support_sqlite.sql create mode 100644 migrations/003_rbac_system_postgres.sql create mode 100644 migrations/README.md create mode 100644 migrations/migration_files.md create mode 100644 monitoring/grafana/dashboards/rustelo-overview.json create mode 100644 monitoring/grafana/provisioning/dashboards/dashboards.yml create mode 100644 monitoring/grafana/provisioning/datasources/datasources.yml create mode 100644 monitoring/prometheus.yml create mode 100644 public/favicon.ico create mode 100644 public/logos/rustelo-imag.svg create mode 100644 public/logos/rustelo_dev-logo-b-h.svg create mode 100644 public/logos/rustelo_dev-logo-b-v.svg create mode 100644 public/logos/rustelo_dev-logo-h.svg create mode 100644 public/logos/rustelo_dev-logo-v.svg create mode 100644 public/website.css create mode 100644 style/main.scss diff --git a/migrations/001_initial_setup.sql b/migrations/001_initial_setup.sql new file mode 100644 index 0000000..2599926 --- /dev/null +++ b/migrations/001_initial_setup.sql @@ -0,0 +1,504 @@ +-- Migration 001: Initial Database Setup +-- This migration creates all necessary tables for authentication and content management + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Users table - core user information +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) UNIQUE NOT NULL, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255), + display_name VARCHAR(100), + avatar_url TEXT, + is_active BOOLEAN DEFAULT TRUE, + email_verified BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + last_login TIMESTAMPTZ, + + -- Profile information + first_name VARCHAR(100), + last_name VARCHAR(100), + bio TEXT, + timezone VARCHAR(50), + locale VARCHAR(10), + preferences JSONB DEFAULT '{}'::jsonb, + + -- Constraints + CONSTRAINT users_email_format CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'), + CONSTRAINT users_username_format CHECK (username ~* '^[A-Za-z0-9_-]{3,50}$'), + CONSTRAINT users_display_name_length CHECK (char_length(display_name) >= 1) +); + +-- User roles table - RBAC implementation +CREATE TABLE user_roles ( + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + role VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (user_id, role), + + -- Constraints + CONSTRAINT user_roles_valid_role CHECK (role IN ('admin', 'moderator', 'user', 'guest') OR role LIKE 'custom_%') +); + +-- OAuth accounts table - external authentication providers +CREATE TABLE oauth_accounts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + provider VARCHAR(50) NOT NULL, + provider_id VARCHAR(255) NOT NULL, + provider_email VARCHAR(255) NOT NULL, + provider_data JSONB NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + -- Constraints + UNIQUE(provider, provider_id), + CONSTRAINT oauth_accounts_valid_provider CHECK (provider IN ('google', 'github', 'discord', 'microsoft') OR provider LIKE 'custom_%') +); + +-- Sessions table - session management +CREATE TABLE sessions ( + id VARCHAR(255) PRIMARY KEY, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + last_accessed TIMESTAMPTZ DEFAULT NOW(), + ip_address INET, + user_agent TEXT, + is_active BOOLEAN DEFAULT TRUE, + + -- Constraints + CONSTRAINT sessions_valid_expiry CHECK (expires_at > created_at) +); + +-- Tokens table - password reset, email verification, etc. +CREATE TABLE tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + token_type VARCHAR(50) NOT NULL, + token_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + used_at TIMESTAMPTZ, + is_active BOOLEAN DEFAULT TRUE, + + -- Constraints + CONSTRAINT tokens_valid_type CHECK (token_type IN ('password_reset', 'email_verification', 'account_activation')), + CONSTRAINT tokens_valid_expiry CHECK (expires_at > created_at), + CONSTRAINT tokens_used_logic CHECK (used_at IS NULL OR used_at >= created_at) +); + +-- Permissions table - for fine-grained access control +CREATE TABLE permissions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + resource VARCHAR(100) NOT NULL, + action VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + + -- Constraints + CONSTRAINT permissions_name_format CHECK (name ~* '^[a-z][a-z0-9_]*[a-z0-9]$'), + CONSTRAINT permissions_resource_format CHECK (resource ~* '^[a-z][a-z0-9_]*[a-z0-9]$'), + CONSTRAINT permissions_action_format CHECK (action IN ('create', 'read', 'update', 'delete', 'manage', 'execute')) +); + +-- Role permissions table - many-to-many relationship +CREATE TABLE role_permissions ( + role VARCHAR(50) NOT NULL, + permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (role, permission_id), + + -- Constraints + CONSTRAINT role_permissions_valid_role CHECK (role IN ('admin', 'moderator', 'user', 'guest') OR role LIKE 'custom_%') +); + +-- User audit log - track important user actions +CREATE TABLE user_audit_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + action VARCHAR(100) NOT NULL, + resource VARCHAR(100), + resource_id UUID, + old_values JSONB, + new_values JSONB, + ip_address INET, + user_agent TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + + -- Constraints + CONSTRAINT audit_log_valid_action CHECK (action IN ('login', 'logout', 'register', 'update_profile', 'change_password', 'oauth_login', 'password_reset', 'email_verify')) +); + +-- Page contents table - main content management +CREATE TABLE page_contents ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + slug VARCHAR(255) NOT NULL UNIQUE, + title VARCHAR(500) NOT NULL, + name VARCHAR(255) NOT NULL, + author VARCHAR(255), + author_id UUID, + content_type VARCHAR(50) NOT NULL DEFAULT 'page', + content_format VARCHAR(20) NOT NULL DEFAULT 'markdown', + content TEXT NOT NULL, + container VARCHAR(255) NOT NULL DEFAULT 'page-container', + state VARCHAR(20) NOT NULL DEFAULT 'draft', + require_login BOOLEAN NOT NULL DEFAULT FALSE, + date_init TIMESTAMPTZ NOT NULL DEFAULT NOW(), + date_end TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + published_at TIMESTAMPTZ, + metadata JSONB DEFAULT '{}', + tags TEXT[] DEFAULT '{}', + category VARCHAR(255), + featured_image VARCHAR(500), + excerpt TEXT, + seo_title VARCHAR(500), + seo_description TEXT, + allow_comments BOOLEAN NOT NULL DEFAULT TRUE, + view_count BIGINT NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT fk_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL +); + +-- Indexes for users table +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_is_active ON users(is_active); +CREATE INDEX idx_users_created_at ON users(created_at); +CREATE INDEX idx_users_email_verified ON users(email_verified); + +-- Indexes for user_roles table +CREATE INDEX idx_user_roles_user_id ON user_roles(user_id); +CREATE INDEX idx_user_roles_role ON user_roles(role); + +-- Indexes for oauth_accounts table +CREATE INDEX idx_oauth_accounts_user_id ON oauth_accounts(user_id); +CREATE INDEX idx_oauth_accounts_provider ON oauth_accounts(provider, provider_id); +CREATE INDEX idx_oauth_accounts_provider_email ON oauth_accounts(provider_email); + +-- Indexes for sessions table +CREATE INDEX idx_sessions_user_id ON sessions(user_id); +CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); +CREATE INDEX idx_sessions_is_active ON sessions(is_active); +CREATE INDEX idx_sessions_last_accessed ON sessions(last_accessed); + +-- Indexes for tokens table +CREATE INDEX idx_tokens_user_id ON tokens(user_id); +CREATE INDEX idx_tokens_type ON tokens(token_type); +CREATE INDEX idx_tokens_expires_at ON tokens(expires_at); +CREATE INDEX idx_tokens_is_active ON tokens(is_active); +CREATE INDEX idx_tokens_hash ON tokens(token_hash); + +-- Indexes for permissions table +CREATE INDEX idx_permissions_name ON permissions(name); +CREATE INDEX idx_permissions_resource ON permissions(resource); +CREATE INDEX idx_permissions_action ON permissions(action); + +-- Indexes for role_permissions table +CREATE INDEX idx_role_permissions_role ON role_permissions(role); +CREATE INDEX idx_role_permissions_permission ON role_permissions(permission_id); + +-- Indexes for user_audit_log table +CREATE INDEX idx_user_audit_log_user_id ON user_audit_log(user_id); +CREATE INDEX idx_user_audit_log_action ON user_audit_log(action); +CREATE INDEX idx_user_audit_log_created_at ON user_audit_log(created_at); + +-- Indexes for page_contents table +CREATE INDEX idx_page_contents_slug ON page_contents(slug); +CREATE INDEX idx_page_contents_state ON page_contents(state); +CREATE INDEX idx_page_contents_content_type ON page_contents(content_type); +CREATE INDEX idx_page_contents_author_id ON page_contents(author_id); +CREATE INDEX idx_page_contents_category ON page_contents(category); +CREATE INDEX idx_page_contents_tags ON page_contents USING GIN(tags); +CREATE INDEX idx_page_contents_published_at ON page_contents(published_at); +CREATE INDEX idx_page_contents_created_at ON page_contents(created_at); +CREATE INDEX idx_page_contents_view_count ON page_contents(view_count); +CREATE INDEX idx_page_contents_sort_order ON page_contents(sort_order); +CREATE INDEX idx_page_contents_metadata ON page_contents USING GIN(metadata); + +-- Full-text search index for page_contents +CREATE INDEX idx_page_contents_search ON page_contents USING GIN( + to_tsvector('english', title || ' ' || COALESCE(content, '') || ' ' || COALESCE(excerpt, '')) +); + +-- Partial indexes for published content +CREATE INDEX idx_page_contents_published_public ON page_contents(created_at DESC) +WHERE state = 'published' AND require_login = FALSE; + +CREATE INDEX idx_page_contents_published_by_type ON page_contents(content_type, created_at DESC) +WHERE state = 'published'; + +-- Function to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Triggers to automatically update updated_at +CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_oauth_accounts_updated_at + BEFORE UPDATE ON oauth_accounts + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_page_contents_updated_at + BEFORE UPDATE ON page_contents + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Function to automatically assign default role to new users +CREATE OR REPLACE FUNCTION assign_default_role() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO user_roles (user_id, role) VALUES (NEW.id, 'user'); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Trigger to assign default role +CREATE TRIGGER assign_default_role_trigger + AFTER INSERT ON users + FOR EACH ROW + EXECUTE FUNCTION assign_default_role(); + +-- Function to log user actions +CREATE OR REPLACE FUNCTION log_user_action( + p_user_id UUID, + p_action VARCHAR(100), + p_resource VARCHAR(100) DEFAULT NULL, + p_resource_id UUID DEFAULT NULL, + p_old_values JSONB DEFAULT NULL, + p_new_values JSONB DEFAULT NULL, + p_ip_address INET DEFAULT NULL, + p_user_agent TEXT DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + log_id UUID; +BEGIN + INSERT INTO user_audit_log ( + user_id, action, resource, resource_id, + old_values, new_values, ip_address, user_agent + ) VALUES ( + p_user_id, p_action, p_resource, p_resource_id, + p_old_values, p_new_values, p_ip_address, p_user_agent + ) RETURNING id INTO log_id; + + RETURN log_id; +END; +$$ LANGUAGE plpgsql; + +-- Function to clean up expired sessions and tokens +CREATE OR REPLACE FUNCTION cleanup_expired_auth_data() +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER := 0; + temp_count INTEGER; +BEGIN + -- Delete expired sessions + DELETE FROM sessions WHERE expires_at < NOW(); + GET DIAGNOSTICS temp_count = ROW_COUNT; + deleted_count := deleted_count + temp_count; + + -- Delete expired tokens + DELETE FROM tokens WHERE expires_at < NOW(); + GET DIAGNOSTICS temp_count = ROW_COUNT; + deleted_count := deleted_count + temp_count; + + -- Delete old audit logs (older than 1 year) + DELETE FROM user_audit_log WHERE created_at < NOW() - INTERVAL '1 year'; + GET DIAGNOSTICS temp_count = ROW_COUNT; + deleted_count := deleted_count + temp_count; + + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql; + +-- Default permissions +INSERT INTO permissions (name, description, resource, action) VALUES +('read_users', 'Read user information', 'users', 'read'), +('write_users', 'Create and update users', 'users', 'create'), +('delete_users', 'Delete users', 'users', 'delete'), +('manage_users', 'Full user management', 'users', 'manage'), +('read_content', 'Read content', 'content', 'read'), +('write_content', 'Create and update content', 'content', 'create'), +('delete_content', 'Delete content', 'content', 'delete'), +('manage_content', 'Full content management', 'content', 'manage'), +('manage_roles', 'Manage user roles and permissions', 'roles', 'manage'), +('manage_system', 'System administration', 'system', 'manage'), +('read_audit_log', 'Read audit logs', 'audit_log', 'read'), +('execute_maintenance', 'Execute maintenance tasks', 'system', 'execute'); + +-- Default role permissions +INSERT INTO role_permissions (role, permission_id) +SELECT 'admin', id FROM permissions; + +INSERT INTO role_permissions (role, permission_id) +SELECT 'moderator', id FROM permissions +WHERE name IN ('read_users', 'read_content', 'write_content', 'delete_content', 'read_audit_log'); + +INSERT INTO role_permissions (role, permission_id) +SELECT 'user', id FROM permissions +WHERE name IN ('read_content', 'write_content'); + +INSERT INTO role_permissions (role, permission_id) +SELECT 'guest', id FROM permissions +WHERE name IN ('read_content'); + +-- Create a default admin user (password: 'admin123' - change this!) +-- Password hash for 'admin123' with Argon2 +INSERT INTO users (email, username, password_hash, display_name, email_verified, is_active) +VALUES ( + 'admin@example.com', + 'admin', + '$argon2id$v=19$m=19456,t=2,p=1$4K5FCBeajDVi8smeWgce3w$y9zZkuvLE3H3GwTFgfl/ngjqlnjiuDRIPiBqu0yFICA', + 'System Administrator', + TRUE, + TRUE +); + +-- Assign admin role to the default admin user +INSERT INTO user_roles (user_id, role) +SELECT id, 'admin' FROM users WHERE username = 'admin'; + +-- Sample page content data +INSERT INTO page_contents ( + slug, title, name, content_type, content, container, state, + require_login, tags, category, excerpt, allow_comments +) VALUES +( + 'welcome', + 'Welcome to Our Site', + 'welcome-page', + 'page', + '# Welcome to Our Site + +Welcome to our amazing website! This is a sample page to demonstrate our content management system. + +## Features + +- Dynamic content loading +- Markdown support +- SEO optimization +- Multi-format content support + +Feel free to explore and discover what we have to offer.', + 'page-container', + 'published', + false, + ARRAY['welcome', 'introduction'], + 'general', + 'Welcome to our amazing website! This is a sample page to demonstrate our content management system.', + false +), +( + 'about', + 'About Us', + 'about-page', + 'page', + '# About Us + +We are a team of passionate developers creating amazing web experiences. + +## Our Mission + +To build innovative solutions that make the web a better place. + +## Our Values + +- Quality +- Innovation +- User Experience +- Open Source', + 'page-container', + 'published', + false, + ARRAY['about', 'company'], + 'general', + 'We are a team of passionate developers creating amazing web experiences.', + false +), +( + 'sample-blog-post', + 'Getting Started with Rust and Web Development', + 'sample-blog', + 'blog', + '# Getting Started with Rust and Web Development + +Rust is becoming increasingly popular for web development, and for good reason! + +## Why Rust for Web Development? + +1. **Performance**: Rust offers near C++ performance +2. **Safety**: Memory safety without garbage collection +3. **Concurrency**: Excellent support for concurrent programming +4. **Ecosystem**: Growing ecosystem of web frameworks + +## Popular Rust Web Frameworks + +- **Axum**: A modern, async web framework +- **Warp**: A super-easy, composable, web server framework +- **Actix-web**: A powerful, pragmatic, and extremely fast web framework + +## Getting Started + +```rust +use axum::{response::Html, routing::get, Router}; + +#[tokio::main] +async fn main() { + let app = Router::new().route("/", get(|| async { Html("

Hello, World!

") })); + + axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()) + .serve(app.into_make_service()) + .await + .unwrap(); +} +``` + +Happy coding!', + 'blog-container', + 'published', + false, + ARRAY['rust', 'web-development', 'programming', 'tutorial'], + 'technology', + 'Learn how to get started with Rust for web development. Discover popular frameworks and see practical examples.', + true +); + +-- Comments on tables +COMMENT ON TABLE users IS 'Core user accounts and profile information'; +COMMENT ON TABLE user_roles IS 'User role assignments for RBAC'; +COMMENT ON TABLE oauth_accounts IS 'External OAuth provider account links'; +COMMENT ON TABLE sessions IS 'User session management'; +COMMENT ON TABLE tokens IS 'Security tokens for password reset, email verification, etc.'; +COMMENT ON TABLE permissions IS 'System permissions for fine-grained access control'; +COMMENT ON TABLE role_permissions IS 'Role to permission mappings'; +COMMENT ON TABLE user_audit_log IS 'Audit trail for user actions'; +COMMENT ON TABLE page_contents IS 'Main content management table for pages, posts, and other content'; + +-- Comments on important columns +COMMENT ON COLUMN users.password_hash IS 'Argon2 hashed password, NULL for OAuth-only accounts'; +COMMENT ON COLUMN users.preferences IS 'JSON object for user preferences and settings'; +COMMENT ON COLUMN oauth_accounts.provider_data IS 'Raw user data from OAuth provider'; +COMMENT ON COLUMN sessions.id IS 'Session identifier, should be cryptographically secure'; +COMMENT ON COLUMN tokens.token_hash IS 'Hashed token value for security'; +COMMENT ON COLUMN user_audit_log.old_values IS 'Previous values before change (for updates)'; +COMMENT ON COLUMN user_audit_log.new_values IS 'New values after change (for updates)'; +COMMENT ON COLUMN page_contents.metadata IS 'JSON object for additional content metadata'; +COMMENT ON COLUMN page_contents.tags IS 'Array of tags for content categorization'; +COMMENT ON COLUMN page_contents.state IS 'Content state: draft, published, archived'; diff --git a/migrations/001_initial_setup_postgres.sql b/migrations/001_initial_setup_postgres.sql new file mode 100644 index 0000000..1e058c9 --- /dev/null +++ b/migrations/001_initial_setup_postgres.sql @@ -0,0 +1,100 @@ +-- Initial database setup for PostgreSQL +-- Migration: 001_initial_setup +-- Database: PostgreSQL + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Users table for authentication +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + username VARCHAR(255) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + is_verified BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- User sessions table +CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Content table for CMS functionality +CREATE TABLE IF NOT EXISTS content ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + content_type VARCHAR(50) NOT NULL DEFAULT 'markdown', + body TEXT, + metadata JSONB, + is_published BOOLEAN NOT NULL DEFAULT false, + published_at TIMESTAMPTZ, + created_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- User roles table +CREATE TABLE IF NOT EXISTS user_roles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + permissions JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- User role assignments +CREATE TABLE IF NOT EXISTS user_role_assignments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES user_roles(id) ON DELETE CASCADE, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + assigned_by UUID REFERENCES users(id), + UNIQUE(user_id, role_id) +); + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active); +CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(session_token); +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at); +CREATE INDEX IF NOT EXISTS idx_content_slug ON content(slug); +CREATE INDEX IF NOT EXISTS idx_content_published ON content(is_published); +CREATE INDEX IF NOT EXISTS idx_content_created_by ON content(created_by); +CREATE INDEX IF NOT EXISTS idx_content_type ON content(content_type); +CREATE INDEX IF NOT EXISTS idx_user_role_assignments_user ON user_role_assignments(user_id); +CREATE INDEX IF NOT EXISTS idx_user_role_assignments_role ON user_role_assignments(role_id); + +-- Update timestamp trigger function +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Apply triggers to tables with updated_at columns +CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_content_updated_at + BEFORE UPDATE ON content + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Insert default roles +INSERT INTO user_roles (name, description, permissions) VALUES +('admin', 'Administrator with full access', '{"all": true}'), +('editor', 'Content editor', '{"content": {"read": true, "write": true, "delete": true}}'), +('user', 'Regular user', '{"content": {"read": true}}') +ON CONFLICT (name) DO NOTHING; diff --git a/migrations/001_initial_setup_sqlite.sql b/migrations/001_initial_setup_sqlite.sql new file mode 100644 index 0000000..001ebc6 --- /dev/null +++ b/migrations/001_initial_setup_sqlite.sql @@ -0,0 +1,96 @@ +-- Initial database setup for SQLite +-- Migration: 001_initial_setup +-- Database: SQLite + +-- Users table for authentication +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + is_verified INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- User sessions table +CREATE TABLE IF NOT EXISTS user_sessions ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_token TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Content table for CMS functionality +CREATE TABLE IF NOT EXISTS content ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + title TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + content_type TEXT NOT NULL DEFAULT 'markdown', + body TEXT, + metadata TEXT, -- JSON as TEXT in SQLite + is_published INTEGER NOT NULL DEFAULT 0, + published_at DATETIME, + created_by TEXT REFERENCES users(id), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- User roles table +CREATE TABLE IF NOT EXISTS user_roles ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + name TEXT NOT NULL UNIQUE, + description TEXT, + permissions TEXT, -- JSON as TEXT in SQLite + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- User role assignments +CREATE TABLE IF NOT EXISTS user_role_assignments ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id TEXT NOT NULL REFERENCES user_roles(id) ON DELETE CASCADE, + assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + assigned_by TEXT REFERENCES users(id), + UNIQUE(user_id, role_id) +); + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active); +CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(session_token); +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at); +CREATE INDEX IF NOT EXISTS idx_content_slug ON content(slug); +CREATE INDEX IF NOT EXISTS idx_content_published ON content(is_published); +CREATE INDEX IF NOT EXISTS idx_content_created_by ON content(created_by); +CREATE INDEX IF NOT EXISTS idx_content_type ON content(content_type); +CREATE INDEX IF NOT EXISTS idx_user_role_assignments_user ON user_role_assignments(user_id); +CREATE INDEX IF NOT EXISTS idx_user_role_assignments_role ON user_role_assignments(role_id); + +-- Triggers for updated_at timestamps (SQLite doesn't have automatic timestamp updates) +CREATE TRIGGER IF NOT EXISTS update_users_updated_at + AFTER UPDATE ON users + FOR EACH ROW + WHEN NEW.updated_at = OLD.updated_at + BEGIN + UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END; + +CREATE TRIGGER IF NOT EXISTS update_content_updated_at + AFTER UPDATE ON content + FOR EACH ROW + WHEN NEW.updated_at = OLD.updated_at + BEGIN + UPDATE content SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END; + +-- Insert default roles +INSERT INTO user_roles (name, description, permissions) VALUES +('admin', 'Administrator with full access', '{"all": true}'), +('editor', 'Content editor', '{"content": {"read": true, "write": true, "delete": true}}'), +('user', 'Regular user', '{"content": {"read": true}}') +ON CONFLICT (name) DO NOTHING; diff --git a/migrations/002_add_2fa_support.sql b/migrations/002_add_2fa_support.sql new file mode 100644 index 0000000..9a22c01 --- /dev/null +++ b/migrations/002_add_2fa_support.sql @@ -0,0 +1,101 @@ +-- Migration 002: Add Two-Factor Authentication Support +-- This migration adds TOTP (Time-based One-Time Password) support for 2FA + +-- User 2FA settings table +CREATE TABLE user_2fa ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE, + secret VARCHAR(32) NOT NULL, -- Base32 encoded TOTP secret + is_enabled BOOLEAN DEFAULT FALSE, + backup_codes TEXT[], -- Array of backup codes + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + last_used TIMESTAMPTZ, + + -- Constraints + CONSTRAINT user_2fa_secret_length CHECK (char_length(secret) = 32), + CONSTRAINT user_2fa_backup_codes_count CHECK (array_length(backup_codes, 1) <= 10) +); + +-- 2FA recovery codes table (for better tracking) +CREATE TABLE user_2fa_recovery_codes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + code_hash VARCHAR(255) NOT NULL, -- Hashed recovery code + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + + -- Constraints + CONSTRAINT user_2fa_recovery_codes_unique UNIQUE(user_id, code_hash) +); + +-- 2FA authentication attempts table (for rate limiting and security) +CREATE TABLE user_2fa_attempts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + ip_address INET, + user_agent TEXT, + success BOOLEAN NOT NULL, + code_type VARCHAR(20) NOT NULL, -- 'totp' or 'backup' + created_at TIMESTAMPTZ DEFAULT NOW(), + + -- Constraints + CONSTRAINT user_2fa_attempts_valid_code_type CHECK (code_type IN ('totp', 'backup')) +); + +-- Add 2FA required flag to users table +ALTER TABLE users ADD COLUMN two_factor_required BOOLEAN DEFAULT FALSE; + +-- Add 2FA verified flag to sessions table +ALTER TABLE sessions ADD COLUMN two_factor_verified BOOLEAN DEFAULT FALSE; + +-- Update tokens table to support 2FA setup tokens +ALTER TABLE tokens DROP CONSTRAINT tokens_valid_type; +ALTER TABLE tokens ADD CONSTRAINT tokens_valid_type CHECK ( + token_type IN ('password_reset', 'email_verification', 'account_activation', '2fa_setup') +); + +-- Indexes for performance +CREATE INDEX idx_user_2fa_user_id ON user_2fa(user_id); +CREATE INDEX idx_user_2fa_recovery_codes_user_id ON user_2fa_recovery_codes(user_id); +CREATE INDEX idx_user_2fa_attempts_user_id ON user_2fa_attempts(user_id); +CREATE INDEX idx_user_2fa_attempts_created_at ON user_2fa_attempts(created_at); +CREATE INDEX idx_sessions_2fa_verified ON sessions(two_factor_verified); + +-- Function to cleanup old 2FA attempts (for maintenance) +CREATE OR REPLACE FUNCTION cleanup_old_2fa_attempts() +RETURNS void AS $$ +BEGIN + DELETE FROM user_2fa_attempts + WHERE created_at < NOW() - INTERVAL '7 days'; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to update updated_at on user_2fa table +CREATE OR REPLACE FUNCTION update_user_2fa_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_user_2fa_updated_at + BEFORE UPDATE ON user_2fa + FOR EACH ROW + EXECUTE FUNCTION update_user_2fa_updated_at(); + +-- Comments for documentation +COMMENT ON TABLE user_2fa IS 'Stores TOTP secrets and 2FA configuration for users'; +COMMENT ON COLUMN user_2fa.secret IS 'Base32 encoded TOTP secret key'; +COMMENT ON COLUMN user_2fa.backup_codes IS 'Array of hashed backup codes for account recovery'; +COMMENT ON COLUMN user_2fa.is_enabled IS 'Whether 2FA is currently enabled for the user'; + +COMMENT ON TABLE user_2fa_recovery_codes IS 'Individual recovery codes for better tracking and management'; +COMMENT ON COLUMN user_2fa_recovery_codes.code_hash IS 'SHA256 hash of the recovery code'; + +COMMENT ON TABLE user_2fa_attempts IS 'Tracks 2FA authentication attempts for security monitoring'; +COMMENT ON COLUMN user_2fa_attempts.code_type IS 'Type of 2FA code used: totp or backup'; + +COMMENT ON COLUMN users.two_factor_required IS 'Whether 2FA is required for this user account'; +COMMENT ON COLUMN sessions.two_factor_verified IS 'Whether this session has completed 2FA verification'; diff --git a/migrations/002_add_2fa_support_postgres.sql b/migrations/002_add_2fa_support_postgres.sql new file mode 100644 index 0000000..0dd0745 --- /dev/null +++ b/migrations/002_add_2fa_support_postgres.sql @@ -0,0 +1,131 @@ +-- Add 2FA support to users table +-- Migration: 002_add_2fa_support +-- Database: PostgreSQL + +-- Add 2FA columns to users table +ALTER TABLE users +ADD COLUMN IF NOT EXISTS two_factor_secret VARCHAR(255), +ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN IF NOT EXISTS backup_codes TEXT[], -- Array of backup codes +ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ, +ADD COLUMN IF NOT EXISTS failed_login_attempts INTEGER NOT NULL DEFAULT 0, +ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ; + +-- Create 2FA recovery codes table +CREATE TABLE IF NOT EXISTS two_factor_recovery_codes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash VARCHAR(255) NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create login attempts table for security tracking +CREATE TABLE IF NOT EXISTS login_attempts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + ip_address INET, + user_agent TEXT, + success BOOLEAN NOT NULL, + failure_reason VARCHAR(255), + attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create password reset tokens table +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create email verification tokens table +CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL, -- Allow email change verification + expires_at TIMESTAMPTZ NOT NULL, + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create indexes for 2FA and security tables +CREATE INDEX IF NOT EXISTS idx_users_2fa_enabled ON users(two_factor_enabled); +CREATE INDEX IF NOT EXISTS idx_users_locked_until ON users(locked_until); +CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login_at); +CREATE INDEX IF NOT EXISTS idx_2fa_recovery_codes_user_id ON two_factor_recovery_codes(user_id); +CREATE INDEX IF NOT EXISTS idx_2fa_recovery_codes_hash ON two_factor_recovery_codes(code_hash); +CREATE INDEX IF NOT EXISTS idx_login_attempts_user_id ON login_attempts(user_id); +CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON login_attempts(ip_address); +CREATE INDEX IF NOT EXISTS idx_login_attempts_time ON login_attempts(attempted_at); +CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash ON password_reset_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_hash ON email_verification_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user ON email_verification_tokens(user_id); + +-- Add constraint to ensure backup codes are valid JSON array +ALTER TABLE users ADD CONSTRAINT backup_codes_valid_json + CHECK (backup_codes IS NULL OR jsonb_typeof(backup_codes::jsonb) = 'array'); + +-- Function to clean up expired tokens +CREATE OR REPLACE FUNCTION cleanup_expired_tokens() +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER := 0; +BEGIN + -- Clean up expired password reset tokens + DELETE FROM password_reset_tokens + WHERE expires_at < NOW() AND used_at IS NULL; + GET DIAGNOSTICS deleted_count = ROW_COUNT; + + -- Clean up expired email verification tokens + DELETE FROM email_verification_tokens + WHERE expires_at < NOW() AND verified_at IS NULL; + GET DIAGNOSTICS deleted_count = deleted_count + ROW_COUNT; + + -- Clean up old login attempts (keep last 30 days) + DELETE FROM login_attempts + WHERE attempted_at < NOW() - INTERVAL '30 days'; + GET DIAGNOSTICS deleted_count = deleted_count + ROW_COUNT; + + -- Clean up expired user sessions + DELETE FROM user_sessions + WHERE expires_at < NOW(); + GET DIAGNOSTICS deleted_count = deleted_count + ROW_COUNT; + + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql; + +-- Function to unlock user accounts after lock period +CREATE OR REPLACE FUNCTION unlock_expired_accounts() +RETURNS INTEGER AS $$ +DECLARE + unlocked_count INTEGER := 0; +BEGIN + UPDATE users + SET locked_until = NULL, + failed_login_attempts = 0 + WHERE locked_until IS NOT NULL + AND locked_until < NOW(); + + GET DIAGNOSTICS unlocked_count = ROW_COUNT; + RETURN unlocked_count; +END; +$$ LANGUAGE plpgsql; + +-- Add comments for documentation +COMMENT ON COLUMN users.two_factor_secret IS 'Base32-encoded TOTP secret for 2FA'; +COMMENT ON COLUMN users.two_factor_enabled IS 'Whether 2FA is enabled for this user'; +COMMENT ON COLUMN users.backup_codes IS 'JSON array of hashed backup codes for 2FA recovery'; +COMMENT ON COLUMN users.last_login_at IS 'Timestamp of last successful login'; +COMMENT ON COLUMN users.failed_login_attempts IS 'Number of consecutive failed login attempts'; +COMMENT ON COLUMN users.locked_until IS 'Account locked until this timestamp due to failed attempts'; + +COMMENT ON TABLE two_factor_recovery_codes IS 'Individual 2FA recovery codes for account recovery'; +COMMENT ON TABLE login_attempts IS 'Log of all login attempts for security monitoring'; +COMMENT ON TABLE password_reset_tokens IS 'Tokens for password reset functionality'; +COMMENT ON TABLE email_verification_tokens IS 'Tokens for email verification and changes'; diff --git a/migrations/002_add_2fa_support_sqlite.sql b/migrations/002_add_2fa_support_sqlite.sql new file mode 100644 index 0000000..355dccf --- /dev/null +++ b/migrations/002_add_2fa_support_sqlite.sql @@ -0,0 +1,117 @@ +-- Add 2FA support to users table +-- Migration: 002_add_2fa_support +-- Database: SQLite + +-- Add 2FA columns to users table (SQLite requires one column at a time) +ALTER TABLE users ADD COLUMN two_factor_secret TEXT; +ALTER TABLE users ADD COLUMN two_factor_enabled INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN backup_codes TEXT; -- JSON array as TEXT +ALTER TABLE users ADD COLUMN last_login_at DATETIME; +ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN locked_until DATETIME; + +-- Create 2FA recovery codes table +CREATE TABLE IF NOT EXISTS two_factor_recovery_codes ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash TEXT NOT NULL, + used_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Create login attempts table for security tracking +CREATE TABLE IF NOT EXISTS login_attempts ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + ip_address TEXT, + user_agent TEXT, + success INTEGER NOT NULL, + failure_reason TEXT, + attempted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Create password reset tokens table +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + used_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Create email verification tokens table +CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + email TEXT NOT NULL, -- Allow email change verification + expires_at DATETIME NOT NULL, + verified_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes for 2FA and security tables +CREATE INDEX IF NOT EXISTS idx_users_2fa_enabled ON users(two_factor_enabled); +CREATE INDEX IF NOT EXISTS idx_users_locked_until ON users(locked_until); +CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login_at); +CREATE INDEX IF NOT EXISTS idx_2fa_recovery_codes_user_id ON two_factor_recovery_codes(user_id); +CREATE INDEX IF NOT EXISTS idx_2fa_recovery_codes_hash ON two_factor_recovery_codes(code_hash); +CREATE INDEX IF NOT EXISTS idx_login_attempts_user_id ON login_attempts(user_id); +CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON login_attempts(ip_address); +CREATE INDEX IF NOT EXISTS idx_login_attempts_time ON login_attempts(attempted_at); +CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash ON password_reset_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_hash ON email_verification_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user ON email_verification_tokens(user_id); + +-- SQLite doesn't support stored procedures, but we can create triggers for cleanup +-- Trigger to automatically clean up expired password reset tokens on insert +CREATE TRIGGER IF NOT EXISTS cleanup_expired_password_tokens + AFTER INSERT ON password_reset_tokens + BEGIN + DELETE FROM password_reset_tokens + WHERE expires_at < datetime('now') AND used_at IS NULL; + END; + +-- Trigger to automatically clean up expired email verification tokens on insert +CREATE TRIGGER IF NOT EXISTS cleanup_expired_email_tokens + AFTER INSERT ON email_verification_tokens + BEGIN + DELETE FROM email_verification_tokens + WHERE expires_at < datetime('now') AND verified_at IS NULL; + END; + +-- Trigger to clean up old login attempts (keep last 1000 entries per user) +CREATE TRIGGER IF NOT EXISTS cleanup_old_login_attempts + AFTER INSERT ON login_attempts + BEGIN + DELETE FROM login_attempts + WHERE id IN ( + SELECT id FROM login_attempts + WHERE user_id = NEW.user_id + ORDER BY attempted_at DESC + LIMIT -1 OFFSET 1000 + ); + END; + +-- Trigger to clean up expired user sessions on new session creation +CREATE TRIGGER IF NOT EXISTS cleanup_expired_sessions + AFTER INSERT ON user_sessions + BEGIN + DELETE FROM user_sessions + WHERE expires_at < datetime('now'); + END; + +-- Trigger to automatically unlock accounts when lock period expires +CREATE TRIGGER IF NOT EXISTS auto_unlock_accounts + BEFORE UPDATE ON users + FOR EACH ROW + WHEN NEW.locked_until IS NOT NULL + AND NEW.locked_until < datetime('now') + BEGIN + UPDATE users + SET locked_until = NULL, + failed_login_attempts = 0 + WHERE id = NEW.id; + END; diff --git a/migrations/003_rbac_system_postgres.sql b/migrations/003_rbac_system_postgres.sql new file mode 100644 index 0000000..c25a84d --- /dev/null +++ b/migrations/003_rbac_system_postgres.sql @@ -0,0 +1,320 @@ +-- RBAC System Migration for PostgreSQL +-- Migration: 003_rbac_system +-- Database: PostgreSQL + +-- User categories table +CREATE TABLE IF NOT EXISTS user_categories ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + parent_id UUID REFERENCES user_categories(id) ON DELETE CASCADE, + metadata JSONB, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- User tags table +CREATE TABLE IF NOT EXISTS user_tags ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + color VARCHAR(7), -- hex color code + metadata JSONB, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- User category assignments +CREATE TABLE IF NOT EXISTS user_category_assignments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + category_id UUID NOT NULL REFERENCES user_categories(id) ON DELETE CASCADE, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + assigned_by UUID REFERENCES users(id), + expires_at TIMESTAMPTZ, + UNIQUE(user_id, category_id) +); + +-- User tag assignments +CREATE TABLE IF NOT EXISTS user_tag_assignments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tag_id UUID NOT NULL REFERENCES user_tags(id) ON DELETE CASCADE, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + assigned_by UUID REFERENCES users(id), + expires_at TIMESTAMPTZ, + UNIQUE(user_id, tag_id) +); + +-- Access rules table +CREATE TABLE IF NOT EXISTS access_rules ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + description TEXT, + resource_type VARCHAR(50) NOT NULL, -- 'database', 'file', 'directory', 'content', 'api' + resource_name VARCHAR(500) NOT NULL, -- supports wildcards + action VARCHAR(50) NOT NULL, -- 'read', 'write', 'delete', 'execute' + priority INTEGER NOT NULL DEFAULT 0, -- higher priority rules evaluated first + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Role requirements for access rules +CREATE TABLE IF NOT EXISTS access_rule_roles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + rule_id UUID NOT NULL REFERENCES access_rules(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES user_roles(id) ON DELETE CASCADE, + UNIQUE(rule_id, role_id) +); + +-- Permission requirements for access rules +CREATE TABLE IF NOT EXISTS access_rule_permissions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + rule_id UUID NOT NULL REFERENCES access_rules(id) ON DELETE CASCADE, + permission_name VARCHAR(100) NOT NULL, + resource_scope VARCHAR(255), -- optional scope for permission + UNIQUE(rule_id, permission_name, resource_scope) +); + +-- Category requirements for access rules +CREATE TABLE IF NOT EXISTS access_rule_categories ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + rule_id UUID NOT NULL REFERENCES access_rules(id) ON DELETE CASCADE, + category_id UUID NOT NULL REFERENCES user_categories(id) ON DELETE CASCADE, + requirement_type VARCHAR(20) NOT NULL DEFAULT 'required', -- 'required', 'denied' + UNIQUE(rule_id, category_id, requirement_type) +); + +-- Tag requirements for access rules +CREATE TABLE IF NOT EXISTS access_rule_tags ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + rule_id UUID NOT NULL REFERENCES access_rules(id) ON DELETE CASCADE, + tag_id UUID NOT NULL REFERENCES user_tags(id) ON DELETE CASCADE, + requirement_type VARCHAR(20) NOT NULL DEFAULT 'required', -- 'required', 'denied' + UNIQUE(rule_id, tag_id, requirement_type) +); + +-- Permission cache for performance +CREATE TABLE IF NOT EXISTS user_permission_cache ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + resource_type VARCHAR(50) NOT NULL, + resource_name VARCHAR(500) NOT NULL, + action VARCHAR(50) NOT NULL, + access_result VARCHAR(20) NOT NULL, -- 'allow', 'deny', 'require_additional_auth' + cache_key VARCHAR(255) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, cache_key) +); + +-- RBAC configuration table for storing TOML configs +CREATE TABLE IF NOT EXISTS rbac_configs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + config_data JSONB NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Audit log for access attempts +CREATE TABLE IF NOT EXISTS access_audit_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + resource_type VARCHAR(50) NOT NULL, + resource_name VARCHAR(500) NOT NULL, + action VARCHAR(50) NOT NULL, + access_result VARCHAR(20) NOT NULL, + rule_id UUID REFERENCES access_rules(id) ON DELETE SET NULL, + ip_address INET, + user_agent TEXT, + session_id VARCHAR(255), + additional_context JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create indexes for performance +CREATE INDEX IF NOT EXISTS idx_user_categories_name ON user_categories(name); +CREATE INDEX IF NOT EXISTS idx_user_categories_parent ON user_categories(parent_id); +CREATE INDEX IF NOT EXISTS idx_user_categories_active ON user_categories(is_active); + +CREATE INDEX IF NOT EXISTS idx_user_tags_name ON user_tags(name); +CREATE INDEX IF NOT EXISTS idx_user_tags_active ON user_tags(is_active); + +CREATE INDEX IF NOT EXISTS idx_user_category_assignments_user ON user_category_assignments(user_id); +CREATE INDEX IF NOT EXISTS idx_user_category_assignments_category ON user_category_assignments(category_id); +CREATE INDEX IF NOT EXISTS idx_user_category_assignments_expires ON user_category_assignments(expires_at); + +CREATE INDEX IF NOT EXISTS idx_user_tag_assignments_user ON user_tag_assignments(user_id); +CREATE INDEX IF NOT EXISTS idx_user_tag_assignments_tag ON user_tag_assignments(tag_id); +CREATE INDEX IF NOT EXISTS idx_user_tag_assignments_expires ON user_tag_assignments(expires_at); + +CREATE INDEX IF NOT EXISTS idx_access_rules_resource ON access_rules(resource_type, resource_name); +CREATE INDEX IF NOT EXISTS idx_access_rules_action ON access_rules(action); +CREATE INDEX IF NOT EXISTS idx_access_rules_priority ON access_rules(priority DESC); +CREATE INDEX IF NOT EXISTS idx_access_rules_active ON access_rules(is_active); + +CREATE INDEX IF NOT EXISTS idx_access_rule_roles_rule ON access_rule_roles(rule_id); +CREATE INDEX IF NOT EXISTS idx_access_rule_roles_role ON access_rule_roles(role_id); + +CREATE INDEX IF NOT EXISTS idx_access_rule_permissions_rule ON access_rule_permissions(rule_id); +CREATE INDEX IF NOT EXISTS idx_access_rule_permissions_name ON access_rule_permissions(permission_name); + +CREATE INDEX IF NOT EXISTS idx_access_rule_categories_rule ON access_rule_categories(rule_id); +CREATE INDEX IF NOT EXISTS idx_access_rule_categories_category ON access_rule_categories(category_id); + +CREATE INDEX IF NOT EXISTS idx_access_rule_tags_rule ON access_rule_tags(rule_id); +CREATE INDEX IF NOT EXISTS idx_access_rule_tags_tag ON access_rule_tags(tag_id); + +CREATE INDEX IF NOT EXISTS idx_user_permission_cache_user ON user_permission_cache(user_id); +CREATE INDEX IF NOT EXISTS idx_user_permission_cache_key ON user_permission_cache(cache_key); +CREATE INDEX IF NOT EXISTS idx_user_permission_cache_expires ON user_permission_cache(expires_at); + +CREATE INDEX IF NOT EXISTS idx_rbac_configs_name ON rbac_configs(name); +CREATE INDEX IF NOT EXISTS idx_rbac_configs_active ON rbac_configs(is_active); + +CREATE INDEX IF NOT EXISTS idx_access_audit_log_user ON access_audit_log(user_id); +CREATE INDEX IF NOT EXISTS idx_access_audit_log_resource ON access_audit_log(resource_type, resource_name); +CREATE INDEX IF NOT EXISTS idx_access_audit_log_created ON access_audit_log(created_at); + +-- Add triggers for updated_at columns +CREATE TRIGGER update_user_categories_updated_at + BEFORE UPDATE ON user_categories + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_user_tags_updated_at + BEFORE UPDATE ON user_tags + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_access_rules_updated_at + BEFORE UPDATE ON access_rules + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_rbac_configs_updated_at + BEFORE UPDATE ON rbac_configs + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Function to clean up expired permission cache +CREATE OR REPLACE FUNCTION cleanup_expired_permission_cache() +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + DELETE FROM user_permission_cache WHERE expires_at < NOW(); + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql; + +-- Function to get user categories (including inherited) +CREATE OR REPLACE FUNCTION get_user_categories(user_uuid UUID) +RETURNS TABLE(category_name VARCHAR(100)) AS $$ +BEGIN + RETURN QUERY + WITH RECURSIVE category_tree AS ( + -- Direct categories + SELECT uc.name, uc.parent_id + FROM user_categories uc + JOIN user_category_assignments uca ON uc.id = uca.category_id + WHERE uca.user_id = user_uuid + AND uc.is_active = true + AND (uca.expires_at IS NULL OR uca.expires_at > NOW()) + + UNION ALL + + -- Parent categories + SELECT uc.name, uc.parent_id + FROM user_categories uc + JOIN category_tree ct ON uc.id = ct.parent_id + WHERE uc.is_active = true + ) + SELECT DISTINCT ct.name + FROM category_tree ct; +END; +$$ LANGUAGE plpgsql; + +-- Function to get user tags +CREATE OR REPLACE FUNCTION get_user_tags(user_uuid UUID) +RETURNS TABLE(tag_name VARCHAR(100)) AS $$ +BEGIN + RETURN QUERY + SELECT ut.name + FROM user_tags ut + JOIN user_tag_assignments uta ON ut.id = uta.tag_id + WHERE uta.user_id = user_uuid + AND ut.is_active = true + AND (uta.expires_at IS NULL OR uta.expires_at > NOW()); +END; +$$ LANGUAGE plpgsql; + +-- Insert default categories +INSERT INTO user_categories (name, description) VALUES +('admin', 'Administrative access category'), +('editor', 'Content editing access category'), +('viewer', 'Read-only access category'), +('finance', 'Financial data access category'), +('hr', 'Human resources access category'), +('it', 'Information technology access category') +ON CONFLICT (name) DO NOTHING; + +-- Insert default tags +INSERT INTO user_tags (name, description, color) VALUES +('sensitive', 'Access to sensitive data', '#ff0000'), +('public', 'Public data access', '#00ff00'), +('internal', 'Internal data access', '#ffff00'), +('confidential', 'Confidential data access', '#ff8800'), +('restricted', 'Restricted access', '#8800ff'), +('temporary', 'Temporary access', '#00ffff') +ON CONFLICT (name) DO NOTHING; + +-- Insert default RBAC configuration +INSERT INTO rbac_configs (name, description, config_data) VALUES +('default', 'Default RBAC configuration', '{ + "rules": [ + { + "id": "admin_full_access", + "resource_type": "Database", + "resource_name": "*", + "allowed_roles": ["Admin"], + "allowed_permissions": [], + "required_categories": [], + "required_tags": [], + "deny_categories": [], + "deny_tags": [], + "is_active": true + }, + { + "id": "editor_content_access", + "resource_type": "Database", + "resource_name": "content*", + "allowed_roles": ["Editor"], + "allowed_permissions": ["WriteContent"], + "required_categories": ["editor"], + "required_tags": [], + "deny_categories": [], + "deny_tags": ["restricted"], + "is_active": true + } + ], + "default_permissions": { + "Database": ["ReadContent"], + "File": ["ReadFile"] + }, + "category_hierarchies": { + "admin": ["editor", "viewer"], + "editor": ["viewer"] + }, + "tag_hierarchies": { + "public": ["internal"], + "internal": ["confidential"], + "confidential": ["restricted"] + }, + "cache_ttl_seconds": 300 +}') +ON CONFLICT (name) DO NOTHING; diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..0e4cdcd --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,316 @@ +# Database Migrations + +This directory contains SQL migration files for the Rustelo application database setup. + +## Overview + +The migration system sets up a complete database with authentication, content management, and auditing capabilities. **Rustelo now supports both PostgreSQL and SQLite** through a database-agnostic architecture. + +## Migration Files + +Rustelo provides **database-specific migration files** to support both PostgreSQL and SQLite: + +### Database-Specific Files +- `001_initial_setup_postgres.sql` - PostgreSQL-optimized schema +- `001_initial_setup_sqlite.sql` - SQLite-optimized schema +- `002_add_2fa_support_postgres.sql` - PostgreSQL 2FA tables +- `002_add_2fa_support_sqlite.sql` - SQLite 2FA tables +- `003_rbac_system_postgres.sql` - PostgreSQL RBAC system + +### Legacy Files +- `001_initial_setup.sql` - Legacy unified file (deprecated) +- `002_add_2fa_support.sql` - Legacy 2FA file (deprecated) + +The migration system **automatically detects your database type** from the connection URL and runs the appropriate migration files. + +### Schema Features + +#### Authentication Tables +- **users** - Core user accounts and profile information +- **user_roles** - User role assignments for RBAC (Role-Based Access Control) +- **oauth_accounts** - External OAuth provider account links +- **sessions** - User session management +- **tokens** - Security tokens for password reset, email verification, etc. +- **permissions** - System permissions for fine-grained access control +- **role_permissions** - Role to permission mappings +- **user_audit_log** - Audit trail for user actions + +#### Content Management Tables +- **page_contents** - Main content management table for pages, posts, and other content + +#### Key Features +- **UUID Primary Keys** - All tables use UUID primary keys for better security +- **Comprehensive Indexing** - Optimized indexes for performance +- **Full-Text Search** - PostgreSQL full-text search capabilities +- **Audit Logging** - Complete audit trail for user actions +- **Role-Based Access Control** - Flexible permission system +- **Automatic Timestamps** - Automatic created_at/updated_at handling +- **Data Validation** - Comprehensive constraints and validation rules + +## Database Schema + +### User Management +```sql +users (id, email, username, password_hash, display_name, ...) +user_roles (user_id, role) +oauth_accounts (id, user_id, provider, provider_id, ...) +sessions (id, user_id, expires_at, ...) +tokens (id, user_id, token_type, token_hash, ...) +``` + +### Authorization +```sql +permissions (id, name, description, resource, action) +role_permissions (role, permission_id) +user_audit_log (id, user_id, action, resource, ...) +``` + +### Content Management +```sql +page_contents (id, slug, title, content, author_id, ...) +``` + +## Default Data + +The migration includes default data: + +### User Roles +- **admin** - Full system access +- **moderator** - Content management and user oversight +- **user** - Basic content creation +- **guest** - Read-only access + +### Default Admin User +- **Username**: admin +- **Email**: admin@example.com +- **Password**: admin123 (⚠️ **CHANGE THIS IMMEDIATELY IN PRODUCTION!**) + +### Sample Content +- Welcome page +- About page +- Sample blog post + +## Functions and Triggers + +### Automatic Triggers +- `update_updated_at_column()` - Updates timestamps automatically +- `assign_default_role()` - Assigns default role to new users + +### Utility Functions +- `log_user_action()` - Logs user actions for auditing +- `cleanup_expired_auth_data()` - Cleans up expired sessions and tokens + +## Running Migrations + +### Using the Built-in Migration Runner (Recommended) +```bash +# The application automatically runs migrations on startup +cargo run + +# Or use the database tool +cargo run --bin db_tool -- migrate +``` + +### Database Type Detection +The migration system automatically detects your database type from the `DATABASE_URL`: + +```bash +# PostgreSQL - runs *_postgres.sql files +DATABASE_URL=postgresql://user:pass@localhost/database_name + +# SQLite - runs *_sqlite.sql files +DATABASE_URL=sqlite:data/app.db +``` + +### Using SQLx CLI +```bash +# Install sqlx-cli +cargo install sqlx-cli + +# PostgreSQL migrations +sqlx migrate run --database-url "postgresql://username:password@localhost/database_name" + +# SQLite migrations +sqlx migrate run --database-url "sqlite:data/app.db" +``` + +### Manual Migration +```bash +# PostgreSQL +psql -U username -d database_name -f 001_initial_setup_postgres.sql + +# SQLite +sqlite3 data/app.db < 001_initial_setup_sqlite.sql +``` + +## Environment Setup + +Configure your database connection using the `DATABASE_URL` environment variable: + +### PostgreSQL +```env +DATABASE_URL=postgresql://username:password@localhost:5432/database_name +``` + +### SQLite +```env +DATABASE_URL=sqlite:data/app.db +``` + +### Additional Database Configuration +```env +# Connection pool settings (optional) +DATABASE_MAX_CONNECTIONS=10 +DATABASE_MIN_CONNECTIONS=1 +DATABASE_CONNECT_TIMEOUT=30 +DATABASE_IDLE_TIMEOUT=600 +DATABASE_MAX_LIFETIME=3600 +``` + +## Security Considerations + +1. **Change Default Admin Password** - The default admin password is `admin123` +2. **Review Permissions** - Customize role permissions based on your needs +3. **Configure OAuth** - Set up OAuth providers if using external authentication +4. **Enable SSL** - Use SSL connections in production (PostgreSQL) +5. **Backup Strategy** - Implement regular database backups +6. **File Permissions** - Secure SQLite database files (chmod 600) +7. **Database Location** - Store SQLite files outside web root + +## Schema Evolution + +For future schema changes: + +1. Create **database-specific** migration files: + - `003_new_feature_postgres.sql` + - `003_new_feature_sqlite.sql` +2. Always use `CREATE TABLE IF NOT EXISTS` for safety +3. Add proper indexes for performance +4. Handle database-specific differences (UUID vs TEXT, etc.) +5. Test migrations on both database types +6. Include rollback scripts when possible +7. Test migrations on a copy of production data + +### Database Differences to Consider +- **UUIDs**: PostgreSQL native vs SQLite TEXT +- **Timestamps**: PostgreSQL native vs SQLite TEXT (ISO 8601) +- **JSON**: PostgreSQL JSONB vs SQLite TEXT +- **Arrays**: PostgreSQL arrays vs SQLite JSON arrays +- **Booleans**: PostgreSQL BOOLEAN vs SQLite INTEGER + +## Performance Notes + +### PostgreSQL +- All tables have appropriate indexes for common queries +- Full-text search is enabled for content +- Partial indexes are used for filtered queries +- GIN indexes are used for JSONB and array columns +- Connection pooling for high concurrency + +### SQLite +- Optimized indexes for single-user scenarios +- WAL mode enabled for better concurrency +- Foreign key constraints enabled +- Query planner optimizations +- Smaller memory footprint + +## Troubleshooting + +### Common Issues + +#### PostgreSQL Issues +1. **Extension Error**: If you get an error about `uuid-ossp` extension, ensure your PostgreSQL user has superuser privileges or the extension is already installed. +2. **Permission Denied**: Ensure your database user has CREATE privileges. +3. **Connection Failed**: Check PostgreSQL service is running and connection details are correct. + +#### SQLite Issues +1. **File Permission Error**: Ensure the SQLite file and directory have proper write permissions. +2. **Database Locked**: Close other connections to the SQLite file. +3. **Directory Not Found**: Ensure the directory for the SQLite file exists. + +#### General Issues +1. **Constraint Violations**: Check that your data meets the constraint requirements (email format, username format, etc.). +2. **Migration Version Mismatch**: Ensure all migration files are present and properly numbered. +3. **Database Type Detection Failed**: Verify your `DATABASE_URL` format is correct. + +### Verification + +After running the migration, verify the setup: + +#### PostgreSQL Verification +```sql +-- Check tables were created +SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'; + +-- Check default admin user +SELECT username, email, is_active FROM users WHERE username = 'admin'; + +-- Check permissions setup +SELECT COUNT(*) FROM permissions; +SELECT COUNT(*) FROM role_permissions; + +-- Check sample content +SELECT slug, title, state FROM page_contents; +``` + +#### SQLite Verification +```sql +-- Check tables were created +SELECT name FROM sqlite_master WHERE type='table'; + +-- Check default admin user +SELECT username, email, is_active FROM users WHERE username = 'admin'; + +-- Check permissions setup +SELECT COUNT(*) FROM permissions; +SELECT COUNT(*) FROM role_permissions; + +-- Check sample content +SELECT slug, title, state FROM page_contents; +``` + +## Backup and Restore + +### PostgreSQL +```bash +# Create backup +pg_dump -U username -d database_name > backup.sql + +# Restore backup +psql -U username -d database_name < backup.sql + +# Compressed backup +pg_dump -U username -d database_name | gzip > backup.sql.gz +``` + +### SQLite +```bash +# Create backup (simple copy) +cp data/app.db data/app_backup.db + +# Create SQL dump +sqlite3 data/app.db .dump > backup.sql + +# Restore from SQL dump +sqlite3 data/app_new.db < backup.sql + +# Online backup (while app is running) +sqlite3 data/app.db ".backup data/app_backup.db" +``` + +### Cross-Database Migration +```bash +# SQLite to PostgreSQL +sqlite3 data/app.db .dump | sed 's/INSERT INTO/INSERT INTO public./g' > backup.sql +psql -U username -d database_name < backup.sql + +# Note: May require manual adjustments for data types +``` + +## Support + +For issues or questions: +1. Check the application logs +2. Verify database connectivity +3. Review the migration file for any custom modifications +4. Consult the PostgreSQL documentation for specific errors \ No newline at end of file diff --git a/migrations/migration_files.md b/migrations/migration_files.md new file mode 100644 index 0000000..cada3bc --- /dev/null +++ b/migrations/migration_files.md @@ -0,0 +1,111 @@ +📊 **Migration File Comparison** + +### **`001_initial_setup.sql` (Comprehensive)** +This is the **full-featured, production-ready** migration that includes: + +**🔐 Authentication & Authorization Tables:** +- `users` - Core user accounts with complete profile information +- `user_roles` - Role-based access control (RBAC) +- `oauth_accounts` - External OAuth provider integrations +- `sessions` - Comprehensive session management +- `tokens` - Password reset, email verification tokens +- `permissions` - Fine-grained permission system +- `role_permissions` - Role-to-permission mappings +- `user_audit_log` - Complete audit trail + +**📝 Content Management:** +- `page_contents` - Full-featured content management system + +**🎯 Advanced Features:** +- Complete PostgreSQL functions and triggers +- Full-text search capabilities +- Comprehensive indexing strategy +- Default admin user and permissions +- Sample content data + +### **`001_initial_setup_postgres.sql` (Basic)** +This is a **simplified, basic version** with only: + +**🔐 Basic Authentication:** +- `users` - Basic user accounts +- `user_sessions` - Simple session management +- `user_roles` - Basic role system +- `user_role_assignments` - User-role relationships + +**📝 Basic Content:** +- `content` - Simple content management + +### **`001_initial_setup_sqlite.sql` (Basic)** +Similar to PostgreSQL version but **adapted for SQLite:** +- Same basic table structure +- SQLite-specific data types (TEXT instead of UUID) +- SQLite-specific syntax adaptations +- No advanced PostgreSQL features + +## 🤔 **Why This Structure Exists?** + +### **1. Flexibility for Different Use Cases** +- **Full Version**: Complete application with all features +- **Basic Versions**: Minimal setup for simple projects or learning + +### **2. Database-Specific Optimizations** +- **PostgreSQL**: Leverages advanced features (UUID, JSONB, functions) +- **SQLite**: Optimized for embedded/lightweight usage + +### **3. Migration Strategy** +The system likely uses: +```rust +// Pseudocode for migration selection +match database_type { + PostgreSQL => { + if features.includes("full_auth") { + run_migration("001_initial_setup.sql") + } else { + run_migration("001_initial_setup_postgres.sql") + } + } + SQLite => run_migration("001_initial_setup_sqlite.sql") +} +``` + +## 📋 **Table Count Comparison** + +### **Full Version (`001_initial_setup.sql`)** +- ✅ `users` (comprehensive with profile fields) +- ✅ `user_roles` (RBAC) +- ✅ `oauth_accounts` (OAuth integration) +- ✅ `sessions` (detailed session management) +- ✅ `tokens` (password reset, verification) +- ✅ `permissions` (fine-grained permissions) +- ✅ `role_permissions` (role-permission mapping) +- ✅ `user_audit_log` (audit trail) +- ✅ `page_contents` (full CMS) + +**Total: 9 tables** + comprehensive functions, triggers, and sample data + +### **Basic Versions** +- ✅ `users` (basic fields only) +- ✅ `user_sessions` (simple sessions) +- ✅ `content` (basic content) +- ✅ `user_roles` (basic roles) +- ✅ `user_role_assignments` (role assignments) + +**Total: 5 tables** + basic triggers + +## 💡 **Recommendation** + +For the Rustelo project, I recommend: + +1. **Use the full version** (`001_initial_setup.sql`) for production applications +2. **Use basic versions** for prototyping or learning +3. **Consider creating a configuration option** to choose migration complexity: + +```bash +# Use full-featured migration +./scripts/db.sh setup create --features full + +# Use basic migration +./scripts/db.sh setup create --features basic +``` + +The full version provides enterprise-grade features like audit logging, OAuth integration, and comprehensive security, while the basic versions are perfect for getting started quickly. diff --git a/monitoring/grafana/dashboards/rustelo-overview.json b/monitoring/grafana/dashboards/rustelo-overview.json new file mode 100644 index 0000000..b4e5922 --- /dev/null +++ b/monitoring/grafana/dashboards/rustelo-overview.json @@ -0,0 +1,886 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rustelo_uptime_seconds", + "refId": "A" + } + ], + "title": "Application Uptime (seconds)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(rustelo_http_requests_total[5m])", + "refId": "A" + } + ], + "title": "HTTP Request Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, rate(rustelo_http_request_duration_seconds_bucket[5m]))", + "refId": "A" + } + ], + "title": "HTTP Request Duration (95th percentile)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rustelo_http_requests_in_flight", + "refId": "A" + } + ], + "title": "HTTP Requests In Flight", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rustelo_db_connections_active", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rustelo_db_connections_idle", + "refId": "B" + } + ], + "title": "Database Connections", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rustelo_memory_usage_bytes", + "refId": "A" + } + ], + "title": "Memory Usage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rustelo_cpu_usage_percent", + "refId": "A" + } + ], + "title": "CPU Usage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(rustelo_auth_requests_total[5m])", + "refId": "A" + } + ], + "title": "Authentication Requests Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rustelo_content_cache_hits_total", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rustelo_content_cache_misses_total", + "refId": "B" + } + ], + "title": "Content Cache Performance", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(rustelo_email_sent_total[5m])", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(rustelo_email_failures_total[5m])", + "refId": "B" + } + ], + "title": "Email Service Performance", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 37, + "style": "dark", + "tags": [ + "rustelo", + "application", + "overview" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Rustelo Application Overview", + "uid": "rustelo-overview", + "version": 1, + "weekStart": "" +} diff --git a/monitoring/grafana/provisioning/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..98533c2 --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,42 @@ +apiVersion: 1 + +providers: + - name: 'default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + + - name: 'rustelo' + orgId: 1 + folder: 'Rustelo' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards/rustelo + + - name: 'system' + orgId: 1 + folder: 'System' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards/system + + - name: 'business' + orgId: 1 + folder: 'Business Metrics' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards/business diff --git a/monitoring/grafana/provisioning/datasources/datasources.yml b/monitoring/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 0000000..9169814 --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/datasources.yml @@ -0,0 +1,37 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true + jsonData: + httpMethod: POST + queryTimeout: 60s + timeInterval: 15s + version: 1 + + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + isDefault: false + editable: true + jsonData: + maxLines: 1000 + timeout: 60s + version: 1 + + - name: Rustelo Health + type: prometheus + access: proxy + url: http://app:3030/metrics/health + isDefault: false + editable: true + jsonData: + httpMethod: GET + queryTimeout: 30s + timeInterval: 30s + version: 1 diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml new file mode 100644 index 0000000..6cc77d0 --- /dev/null +++ b/monitoring/prometheus.yml @@ -0,0 +1,67 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + - "rules/*.yml" + +scrape_configs: + # Rustelo application metrics + - job_name: 'rustelo-app' + static_configs: + - targets: ['app:3030'] + scrape_interval: 15s + metrics_path: '/metrics' + scheme: http + scrape_timeout: 10s + + # Prometheus self-monitoring + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + # Node exporter for system metrics (optional) + - job_name: 'node-exporter' + static_configs: + - targets: ['node-exporter:9100'] + scrape_interval: 15s + + # PostgreSQL metrics (optional) + - job_name: 'postgres-exporter' + static_configs: + - targets: ['postgres-exporter:9187'] + scrape_interval: 30s + + # Redis metrics (optional) + - job_name: 'redis-exporter' + static_configs: + - targets: ['redis-exporter:9121'] + scrape_interval: 30s + + # Nginx metrics (optional) + - job_name: 'nginx-exporter' + static_configs: + - targets: ['nginx-exporter:9113'] + scrape_interval: 30s + + # Custom health check endpoint + - job_name: 'rustelo-health' + static_configs: + - targets: ['app:3030'] + scrape_interval: 30s + metrics_path: '/health' + scheme: http + scrape_timeout: 10s + +alerting: + alertmanagers: + - static_configs: + - targets: + - alertmanager:9093 + +# Storage configuration +storage: + tsdb: + path: /prometheus + retention.time: 30d + retention.size: 10GB diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..2ba8527cb12f5f28f331b8d361eef560492d4c77 GIT binary patch literal 15406 zcmeHOd3aPs5`TblWD*3D%tXPJ#q(n!z$P=3gCjvf#a)E}a;Uf>h{pmVih!a-5LVO` zB?JrzEFicD0wRLo0iPfO372xnkvkzFlRHB)lcTnNZ}KK@US{UKN#b8?e_zkLy1RZ= zT~*y(-6IICgf>E_P6A)M3(wvl2qr-gx_5Ux-_uzT*6_Q&ee1v9B?vzS3&K5IhO2N5 z$9ukLN<`G>$$|GLnga~y%>f}*j%+w@(ixVUb^1_Gjoc;(?TrD3m2)RduFblVN)uy; zQAEd^T{5>-YYH%|Kv{V^cxHMBr1Ik7Frht$imC`rqx@5*| z+OqN!xAjqmaU=qR$uGDMa7p!W9oZ+64($4xDk^FyFQ<_9Z`(;DLnB<;LLJD1<&vnZ zo0(>zIkQTse}qNMb6+i`th54(3pKm8;UAJ<_BULR*Z=m5FU7jiW(&#l+}WkHZ|e@1 z`pm;Q^pCuLUQUrnQ(hPM10pSSHQS=Bf8DqG1&!-B!oQQ|FuzLruL1w(+g<8&znyI? zzX-}?SwUvNjEuT?7uUOy{Fb@xKklpj+jdYM^IK9}NxvLRZd{l9FHEQJ4IO~q%4I0O zAN|*8x^nIU4Giw?f*tmNx=7H)2-Zn?J^B6SgpcW3ZXV_57Sn%Mtfr_=w|sYpAhdJT zcKo6Z*oIOU(az~3$LOEWm9Q)dYWMA}T7L23MVGqrcA%4H)+^`+=j+Hh8CTCnnG2Rh zgcXVW%F8$R9)6}f=NQiLPt8qt3xNUQI>Q*)H1lzk<&n?XR-f}tc&9V0H0lhGqHJ^N zN%h(9-Of2_)!Xk{qdIkU>1%mk%I_Id1!MU*yq&&>)Q+!L^t&-2mW9Xq7g9C@* zl&PKJ&su2L+iku?Te?Pf?k3tUK){Bj_gb&aPo8Ago^XI~mRTd(5{&^tf1)!-lSMha z@$~ae!r(~`=p&|mMxy2EiZQ6FvXb(1avS*`Pj%$)*?vwceGKHmHnl`v&fEQ_Wh+G) zEPQ^3&oV%}%;zF`AM|S%d>pM@1}33PN5*4SewROk_K$n^i8QjaYiRzwG8#OvVIF|{x85wH+?*P*%)woI zR538k@=(E`V;p1UwA|fqSh`$n_t;Sz4T)`_s~pRR4lbmWWSdxa-FqLZ%fLT)Bh?iye?COx~mO1wkn5)HNMg7`8~ z25VJhz&3Z7`M>6luJrEw$Jikft+6SxyIh?)PU1?DfrKMGC z=3T;;omE4H`PWqF8?0*dOA3o9y@~WK`S}{?tIHquEw?v`M^D%Lobpdrp%3}1=-&qk zqAtb1px-1Fy6}E8IUg4s%8B0~P<P5C;de%@n~XnDKF@fr$a+^@$^P|>vlw($aSK2lRtLt~8tRb`I0 znfI!G?K|<5ry*gk>y56rZy0NkK6)))6Mg1=K?7yS9p+#1Ij=W*%5Rt-mlc;#MOnE9 zoi`-+6oj@)`gq2Af!B+9%J#K9V=ji2dj2<_qaLSXOCeqQ&<0zMSb$5mAi;HU=v`v<>NYk}MbD!ewYVB+N-ctzn=l&bTwv)*7 zmY<+Y@SBbtl9PPk$HTR?ln@(T92XjTRj0Mx|Mzl;lW>Su_y^~fh?8(L?oz8h!cCpb zZG-OY=NJ3{>r*`U<(J%#zjFT-a9>u6+23H{=d(utkgqt7@^)C;pkb)fQ|Q=*8*SyT z;otKe+f8fEp)ZacKZDn3TNzs>_Kx+g*c_mr8LBhr8GnoEmAQk#%sR52`bdbW8Ms$!0u2bdt=T-lK3JbDW`F(Urt%Ob2seiN>7U`YN}aOdIiCC;eeufJC#m3S z9#|l2c?G@t*hH5y^76jkv)rs4H+;oiTuY5FQwRMN_7NUqeiD|b&RyxPXQz|3qC(_> zZJMwjC4F!1m2INXqzisQ4X^w=>&(+Ecdu&~IWEMn7f*YcYI&eWI(6hI#f114%aymM zyhlG6{q>XN7(LyGiMAS&qijR%d2rV|>AUT_sE&EKUSTCM26>aKzNxk0?K|utOcxl# zxIOwM#O!!H+QzbX*&p=QuKe4y;bS>&StQOE5AEGg_ubk8{;1yOVAJfE_Js-lL7rr9 z)CEuFIlkApj~uV^zJK7KocjT=4B zJP(}0x}|A7C$$5gIp>KBPZ|A#2Ew;$#g9Fk)r;Q~?G$>x<+JM)J3u>j zi68K=I;ld`JJ?Nq+^_B?C+Q%+x#m{9JF$tbaDeNIep%=^#>KHGtg=L)>m z_J&vaZTs2{qP!4Gdw5u5Kcf}5R4(q}Lebx%(J$7l*Q`Il#pCTM%!`y5y*-~zIVs}D z9;t+(xmV~R65^ZQXe+<5{$QW0O8MT~a{kdFLR)nfRMA9L(YU>x*DTltN#m-2km zC;T`cfb{c`mcx(z7o_a8bYJn8_^dz4Cq!DZ37{P6uF{@#519UWK1{>(9sZB1I^6MmNc39MJ-_|)!S8vO+O3&$MulU3Gc z_W{N*B(yneyl-oN_MKaJ{CZ6dv-~^8uPbLSh&0jfV@EfA{2Dc!_rOyfx`R0T@LonA z<*%O?-aa_Wm-z$s@K(ex7UhM0-?9C=PkYdk&d2n((E4>&(f4D`fOQY%CURMMyJyU` zVeJBAId&StHjw76tnwSqZs3e0683`L{a3k9JYdg#(ZVw4J`&CkV-2LFaDE1Z?CehVy%vZx$tM3tTax8E@2;N^QTrPcI?Ob8uK!DM0_sfE6ks2M?iw zPS4{(k-PF*-oY>S!d9;L+|xdTtLen9B2LvpL4k;#ScB< z$NP_7j~7)5eXuoYEk*dK_rSz9yT_C4B{r~^#^o}-VQI=Y?01|$aa!a7=UEm$|DsQQ zfLK1qmho2@)nwA?$1%T6jwO2HZ({6&;`s|OQOxI4S8*Hw=Qp!b(gNJR%SAj&wGa>^&2@x)Vj zhd^WfzJ^b0O{E^q82Pw({uT`E`MT2WnZ02{E%t*yRPN>?W>0vU^4@Vyh4;mLj918c z*s*papo?<}cQM{5lcgZScx}?usg{mS!KkH9U%@|^_33?{FI{1ss+8kXyFY&5M-e~f zM$){FF;_+z3sNJ)Er~{Beux$fEl{R4|7WKcpEsGtK57f+H0DJ$hI;U;JtF>+lG@sV zQI_;bQ^7XIJ>Bs?C32b1v;am;P4GUqAJ#zOHv}4SmV|xXX6~O9&e_~YCCpbT>s$`! k<4FtN!5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/logos/rustelo_dev-logo-b-h.svg b/public/logos/rustelo_dev-logo-b-h.svg new file mode 100644 index 0000000..13dc0d5 --- /dev/null +++ b/public/logos/rustelo_dev-logo-b-h.svg @@ -0,0 +1,307 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Rustelo + dev + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/logos/rustelo_dev-logo-b-v.svg b/public/logos/rustelo_dev-logo-b-v.svg new file mode 100644 index 0000000..070bca2 --- /dev/null +++ b/public/logos/rustelo_dev-logo-b-v.svg @@ -0,0 +1,307 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + dev + Rustelo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/logos/rustelo_dev-logo-h.svg b/public/logos/rustelo_dev-logo-h.svg new file mode 100644 index 0000000..5576400 --- /dev/null +++ b/public/logos/rustelo_dev-logo-h.svg @@ -0,0 +1,307 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Rustelo + dev + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/logos/rustelo_dev-logo-v.svg b/public/logos/rustelo_dev-logo-v.svg new file mode 100644 index 0000000..65c3822 --- /dev/null +++ b/public/logos/rustelo_dev-logo-v.svg @@ -0,0 +1,307 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + dev + Rustelo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/website.css b/public/website.css new file mode 100644 index 0000000..eb3d53e --- /dev/null +++ b/public/website.css @@ -0,0 +1,2 @@ +/* layer: preflights */ +*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;} \ No newline at end of file diff --git a/style/main.scss b/style/main.scss new file mode 100644 index 0000000..d60bf62 --- /dev/null +++ b/style/main.scss @@ -0,0 +1,9 @@ +/* layer: preflights */ +*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;} +/* layer: default */ +@keyframes bounce-alt{from,20%,53%,80%,to{animation-timing-function:cubic-bezier(0.215,0.61,0.355,1);transform:translate3d(0,0,0)}40%,43%{animation-timing-function:cubic-bezier(0.755,0.05,0.855,0.06);transform:translate3d(0,-30px,0)}70%{animation-timing-function:cubic-bezier(0.755,0.05,0.855,0.06);transform:translate3d(0,-15px,0)}90%{transform:translate3d(0,-4px,0)}} +.animate-bounce-alt{animation:bounce-alt 1s linear infinite;transform-origin:center bottom;} +.animate-duration-1s{animation-duration:1s;} +.animate-count-1{animation-iteration-count:1;} +.text-5xl{font-size:3rem;line-height:1;} +.font-thin{font-weight:100;} \ No newline at end of file