Files
databasus/backend/internal/features/databases/service.go
2025-12-25 21:26:53 +03:00

698 lines
17 KiB
Go

package databases
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
audit_logs "databasus-backend/internal/features/audit_logs"
"databasus-backend/internal/features/databases/databases/mariadb"
"databasus-backend/internal/features/databases/databases/mongodb"
"databasus-backend/internal/features/databases/databases/mysql"
"databasus-backend/internal/features/databases/databases/postgresql"
"databasus-backend/internal/features/notifiers"
users_models "databasus-backend/internal/features/users/models"
workspaces_services "databasus-backend/internal/features/workspaces/services"
"databasus-backend/internal/util/encryption"
"github.com/google/uuid"
)
type DatabaseService struct {
dbRepository *DatabaseRepository
notifierService *notifiers.NotifierService
logger *slog.Logger
dbCreationListener []DatabaseCreationListener
dbRemoveListener []DatabaseRemoveListener
dbCopyListener []DatabaseCopyListener
workspaceService *workspaces_services.WorkspaceService
auditLogService *audit_logs.AuditLogService
fieldEncryptor encryption.FieldEncryptor
}
func (s *DatabaseService) AddDbCreationListener(
dbCreationListener DatabaseCreationListener,
) {
s.dbCreationListener = append(s.dbCreationListener, dbCreationListener)
}
func (s *DatabaseService) AddDbRemoveListener(
dbRemoveListener DatabaseRemoveListener,
) {
s.dbRemoveListener = append(s.dbRemoveListener, dbRemoveListener)
}
func (s *DatabaseService) AddDbCopyListener(
dbCopyListener DatabaseCopyListener,
) {
s.dbCopyListener = append(s.dbCopyListener, dbCopyListener)
}
func (s *DatabaseService) CreateDatabase(
user *users_models.User,
workspaceID uuid.UUID,
database *Database,
) (*Database, error) {
canManage, err := s.workspaceService.CanUserManageDBs(workspaceID, user)
if err != nil {
return nil, err
}
if !canManage {
return nil, errors.New("insufficient permissions to create database in this workspace")
}
database.WorkspaceID = &workspaceID
if err := database.Validate(); err != nil {
return nil, err
}
if err := database.PopulateVersionIfEmpty(s.logger, s.fieldEncryptor); err != nil {
return nil, fmt.Errorf("failed to auto-detect database version: %w", err)
}
if err := database.EncryptSensitiveFields(s.fieldEncryptor); err != nil {
return nil, fmt.Errorf("failed to encrypt sensitive fields: %w", err)
}
database, err = s.dbRepository.Save(database)
if err != nil {
return nil, err
}
for _, listener := range s.dbCreationListener {
listener.OnDatabaseCreated(database.ID)
}
s.auditLogService.WriteAuditLog(
fmt.Sprintf("Database created: %s", database.Name),
&user.ID,
&workspaceID,
)
return database, nil
}
func (s *DatabaseService) UpdateDatabase(
user *users_models.User,
database *Database,
) error {
if database.ID == uuid.Nil {
return errors.New("database ID is required for update")
}
existingDatabase, err := s.dbRepository.FindByID(database.ID)
if err != nil {
return err
}
if existingDatabase.WorkspaceID == nil {
return errors.New("cannot update database without workspace")
}
canManage, err := s.workspaceService.CanUserManageDBs(*existingDatabase.WorkspaceID, user)
if err != nil {
return err
}
if !canManage {
return errors.New("insufficient permissions to update this database")
}
if err := database.ValidateUpdate(*existingDatabase, *database); err != nil {
return err
}
existingDatabase.Update(database)
if err := existingDatabase.Validate(); err != nil {
return err
}
if err := existingDatabase.PopulateVersionIfEmpty(s.logger, s.fieldEncryptor); err != nil {
return fmt.Errorf("failed to auto-detect database version: %w", err)
}
if err := existingDatabase.EncryptSensitiveFields(s.fieldEncryptor); err != nil {
return fmt.Errorf("failed to encrypt sensitive fields: %w", err)
}
_, err = s.dbRepository.Save(existingDatabase)
if err != nil {
return err
}
s.auditLogService.WriteAuditLog(
fmt.Sprintf("Database updated: %s", existingDatabase.Name),
&user.ID,
existingDatabase.WorkspaceID,
)
return nil
}
func (s *DatabaseService) DeleteDatabase(
user *users_models.User,
id uuid.UUID,
) error {
existingDatabase, err := s.dbRepository.FindByID(id)
if err != nil {
return err
}
if existingDatabase.WorkspaceID == nil {
return errors.New("cannot delete database without workspace")
}
canManage, err := s.workspaceService.CanUserManageDBs(*existingDatabase.WorkspaceID, user)
if err != nil {
return err
}
if !canManage {
return errors.New("insufficient permissions to delete this database")
}
for _, listener := range s.dbRemoveListener {
if err := listener.OnBeforeDatabaseRemove(id); err != nil {
return err
}
}
s.auditLogService.WriteAuditLog(
fmt.Sprintf("Database deleted: %s", existingDatabase.Name),
&user.ID,
existingDatabase.WorkspaceID,
)
return s.dbRepository.Delete(id)
}
func (s *DatabaseService) GetDatabase(
user *users_models.User,
id uuid.UUID,
) (*Database, error) {
database, err := s.dbRepository.FindByID(id)
if err != nil {
return nil, err
}
if database.WorkspaceID == nil {
return nil, errors.New("cannot access database without workspace")
}
canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(*database.WorkspaceID, user)
if err != nil {
return nil, err
}
if !canAccess {
return nil, errors.New("insufficient permissions to access this database")
}
database.HideSensitiveData()
return database, nil
}
func (s *DatabaseService) GetDatabasesByWorkspace(
user *users_models.User,
workspaceID uuid.UUID,
) ([]*Database, error) {
canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(workspaceID, user)
if err != nil {
return nil, err
}
if !canAccess {
return nil, errors.New("insufficient permissions to access this workspace")
}
databases, err := s.dbRepository.FindByWorkspaceID(workspaceID)
if err != nil {
return nil, err
}
for _, database := range databases {
database.HideSensitiveData()
}
return databases, nil
}
func (s *DatabaseService) IsNotifierUsing(
user *users_models.User,
notifierID uuid.UUID,
) (bool, error) {
_, err := s.notifierService.GetNotifier(user, notifierID)
if err != nil {
return false, err
}
return s.dbRepository.IsNotifierUsing(notifierID)
}
func (s *DatabaseService) TestDatabaseConnection(
user *users_models.User,
databaseID uuid.UUID,
) error {
database, err := s.dbRepository.FindByID(databaseID)
if err != nil {
return err
}
if database.WorkspaceID == nil {
return errors.New("cannot test connection for database without workspace")
}
canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(*database.WorkspaceID, user)
if err != nil {
return err
}
if !canAccess {
return errors.New("insufficient permissions to test connection for this database")
}
err = database.TestConnection(s.logger, s.fieldEncryptor)
if err != nil {
lastSaveError := err.Error()
database.LastBackupErrorMessage = &lastSaveError
return err
}
database.LastBackupErrorMessage = nil
_, err = s.dbRepository.Save(database)
if err != nil {
return err
}
return nil
}
func (s *DatabaseService) TestDatabaseConnectionDirect(
database *Database,
) error {
var usingDatabase *Database
if database.ID != uuid.Nil {
existingDatabase, err := s.dbRepository.FindByID(database.ID)
if err != nil {
return err
}
if database.WorkspaceID != nil && existingDatabase.WorkspaceID != nil &&
*existingDatabase.WorkspaceID != *database.WorkspaceID {
return errors.New("database does not belong to this workspace")
}
existingDatabase.Update(database)
if err := existingDatabase.Validate(); err != nil {
return err
}
usingDatabase = existingDatabase
} else {
usingDatabase = database
}
return usingDatabase.TestConnection(s.logger, s.fieldEncryptor)
}
func (s *DatabaseService) GetDatabaseByID(
id uuid.UUID,
) (*Database, error) {
return s.dbRepository.FindByID(id)
}
func (s *DatabaseService) GetAllDatabases() ([]*Database, error) {
return s.dbRepository.GetAllDatabases()
}
func (s *DatabaseService) SetBackupError(databaseID uuid.UUID, errorMessage string) error {
database, err := s.dbRepository.FindByID(databaseID)
if err != nil {
return err
}
database.LastBackupErrorMessage = &errorMessage
_, err = s.dbRepository.Save(database)
if err != nil {
return err
}
return nil
}
func (s *DatabaseService) SetLastBackupTime(databaseID uuid.UUID, backupTime time.Time) error {
database, err := s.dbRepository.FindByID(databaseID)
if err != nil {
return err
}
database.LastBackupTime = &backupTime
database.LastBackupErrorMessage = nil // Clear any previous error
_, err = s.dbRepository.Save(database)
if err != nil {
return err
}
return nil
}
func (s *DatabaseService) CopyDatabase(
user *users_models.User,
databaseID uuid.UUID,
) (*Database, error) {
existingDatabase, err := s.dbRepository.FindByID(databaseID)
if err != nil {
return nil, err
}
if existingDatabase.WorkspaceID == nil {
return nil, errors.New("cannot copy database without workspace")
}
canManage, err := s.workspaceService.CanUserManageDBs(*existingDatabase.WorkspaceID, user)
if err != nil {
return nil, err
}
if !canManage {
return nil, errors.New("insufficient permissions to copy this database")
}
newDatabase := &Database{
ID: uuid.Nil,
WorkspaceID: existingDatabase.WorkspaceID,
Name: existingDatabase.Name + " (Copy)",
Type: existingDatabase.Type,
Notifiers: existingDatabase.Notifiers,
LastBackupTime: nil,
LastBackupErrorMessage: nil,
HealthStatus: existingDatabase.HealthStatus,
}
switch existingDatabase.Type {
case DatabaseTypePostgres:
if existingDatabase.Postgresql != nil {
newDatabase.Postgresql = &postgresql.PostgresqlDatabase{
ID: uuid.Nil,
DatabaseID: nil,
Version: existingDatabase.Postgresql.Version,
Host: existingDatabase.Postgresql.Host,
Port: existingDatabase.Postgresql.Port,
Username: existingDatabase.Postgresql.Username,
Password: existingDatabase.Postgresql.Password,
Database: existingDatabase.Postgresql.Database,
IsHttps: existingDatabase.Postgresql.IsHttps,
}
}
case DatabaseTypeMysql:
if existingDatabase.Mysql != nil {
newDatabase.Mysql = &mysql.MysqlDatabase{
ID: uuid.Nil,
DatabaseID: nil,
Version: existingDatabase.Mysql.Version,
Host: existingDatabase.Mysql.Host,
Port: existingDatabase.Mysql.Port,
Username: existingDatabase.Mysql.Username,
Password: existingDatabase.Mysql.Password,
Database: existingDatabase.Mysql.Database,
IsHttps: existingDatabase.Mysql.IsHttps,
}
}
case DatabaseTypeMariadb:
if existingDatabase.Mariadb != nil {
newDatabase.Mariadb = &mariadb.MariadbDatabase{
ID: uuid.Nil,
DatabaseID: nil,
Version: existingDatabase.Mariadb.Version,
Host: existingDatabase.Mariadb.Host,
Port: existingDatabase.Mariadb.Port,
Username: existingDatabase.Mariadb.Username,
Password: existingDatabase.Mariadb.Password,
Database: existingDatabase.Mariadb.Database,
IsHttps: existingDatabase.Mariadb.IsHttps,
}
}
case DatabaseTypeMongodb:
if existingDatabase.Mongodb != nil {
newDatabase.Mongodb = &mongodb.MongodbDatabase{
ID: uuid.Nil,
DatabaseID: nil,
Version: existingDatabase.Mongodb.Version,
Host: existingDatabase.Mongodb.Host,
Port: existingDatabase.Mongodb.Port,
Username: existingDatabase.Mongodb.Username,
Password: existingDatabase.Mongodb.Password,
Database: existingDatabase.Mongodb.Database,
AuthDatabase: existingDatabase.Mongodb.AuthDatabase,
IsHttps: existingDatabase.Mongodb.IsHttps,
}
}
}
if err := newDatabase.Validate(); err != nil {
return nil, err
}
copiedDatabase, err := s.dbRepository.Save(newDatabase)
if err != nil {
return nil, err
}
for _, listener := range s.dbCreationListener {
listener.OnDatabaseCreated(copiedDatabase.ID)
}
for _, listener := range s.dbCopyListener {
listener.OnDatabaseCopied(databaseID, copiedDatabase.ID)
}
s.auditLogService.WriteAuditLog(
fmt.Sprintf("Database copied: %s to %s", existingDatabase.Name, copiedDatabase.Name),
&user.ID,
existingDatabase.WorkspaceID,
)
return copiedDatabase, nil
}
func (s *DatabaseService) SetHealthStatus(
databaseID uuid.UUID,
healthStatus *HealthStatus,
) error {
database, err := s.dbRepository.FindByID(databaseID)
if err != nil {
return err
}
database.HealthStatus = healthStatus
_, err = s.dbRepository.Save(database)
if err != nil {
return err
}
return nil
}
func (s *DatabaseService) OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error {
databases, err := s.dbRepository.FindByWorkspaceID(workspaceID)
if err != nil {
return err
}
if len(databases) > 0 {
return fmt.Errorf(
"workspace contains %d databases that must be deleted",
len(databases),
)
}
return nil
}
func (s *DatabaseService) IsUserReadOnly(
user *users_models.User,
database *Database,
) (bool, error) {
var usingDatabase *Database
if database.ID != uuid.Nil {
existingDatabase, err := s.dbRepository.FindByID(database.ID)
if err != nil {
return false, err
}
if existingDatabase.WorkspaceID == nil {
return false, errors.New("cannot check user for database without workspace")
}
canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(
*existingDatabase.WorkspaceID,
user,
)
if err != nil {
return false, err
}
if !canAccess {
return false, errors.New("insufficient permissions to access this database")
}
if database.WorkspaceID != nil && *existingDatabase.WorkspaceID != *database.WorkspaceID {
return false, errors.New("database does not belong to this workspace")
}
existingDatabase.Update(database)
if err := existingDatabase.Validate(); err != nil {
return false, err
}
usingDatabase = existingDatabase
} else {
if database.WorkspaceID != nil {
canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(*database.WorkspaceID, user)
if err != nil {
return false, err
}
if !canAccess {
return false, errors.New("insufficient permissions to access this workspace")
}
}
usingDatabase = database
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
switch usingDatabase.Type {
case DatabaseTypePostgres:
return usingDatabase.Postgresql.IsUserReadOnly(
ctx,
s.logger,
s.fieldEncryptor,
usingDatabase.ID,
)
case DatabaseTypeMysql:
return usingDatabase.Mysql.IsUserReadOnly(
ctx,
s.logger,
s.fieldEncryptor,
usingDatabase.ID,
)
case DatabaseTypeMariadb:
return usingDatabase.Mariadb.IsUserReadOnly(
ctx,
s.logger,
s.fieldEncryptor,
usingDatabase.ID,
)
case DatabaseTypeMongodb:
return usingDatabase.Mongodb.IsUserReadOnly(
ctx,
s.logger,
s.fieldEncryptor,
usingDatabase.ID,
)
default:
return false, errors.New("read-only check not supported for this database type")
}
}
func (s *DatabaseService) CreateReadOnlyUser(
user *users_models.User,
database *Database,
) (string, string, error) {
var usingDatabase *Database
if database.ID != uuid.Nil {
existingDatabase, err := s.dbRepository.FindByID(database.ID)
if err != nil {
return "", "", err
}
if existingDatabase.WorkspaceID == nil {
return "", "", errors.New("cannot create user for database without workspace")
}
canManage, err := s.workspaceService.CanUserManageDBs(*existingDatabase.WorkspaceID, user)
if err != nil {
return "", "", err
}
if !canManage {
return "", "", errors.New("insufficient permissions to manage this database")
}
if database.WorkspaceID != nil && *existingDatabase.WorkspaceID != *database.WorkspaceID {
return "", "", errors.New("database does not belong to this workspace")
}
existingDatabase.Update(database)
if err := existingDatabase.Validate(); err != nil {
return "", "", err
}
usingDatabase = existingDatabase
} else {
if database.WorkspaceID != nil {
canManage, err := s.workspaceService.CanUserManageDBs(*database.WorkspaceID, user)
if err != nil {
return "", "", err
}
if !canManage {
return "", "", errors.New("insufficient permissions to manage this workspace")
}
}
usingDatabase = database
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var username, password string
var err error
switch usingDatabase.Type {
case DatabaseTypePostgres:
username, password, err = usingDatabase.Postgresql.CreateReadOnlyUser(
ctx, s.logger, s.fieldEncryptor, usingDatabase.ID,
)
case DatabaseTypeMysql:
username, password, err = usingDatabase.Mysql.CreateReadOnlyUser(
ctx, s.logger, s.fieldEncryptor, usingDatabase.ID,
)
case DatabaseTypeMariadb:
username, password, err = usingDatabase.Mariadb.CreateReadOnlyUser(
ctx, s.logger, s.fieldEncryptor, usingDatabase.ID,
)
case DatabaseTypeMongodb:
username, password, err = usingDatabase.Mongodb.CreateReadOnlyUser(
ctx, s.logger, s.fieldEncryptor, usingDatabase.ID,
)
default:
return "", "", errors.New("read-only user creation not supported for this database type")
}
if err != nil {
return "", "", err
}
if usingDatabase.WorkspaceID != nil {
s.auditLogService.WriteAuditLog(
fmt.Sprintf(
"Read-only user created for database: %s (username: %s)",
usingDatabase.Name,
username,
),
&user.ID,
usingDatabase.WorkspaceID,
)
}
return username, password, nil
}