Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

View file

@ -0,0 +1,165 @@
package gateway
import (
"context"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service"
gatewayuc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/gateway"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/jwt"
)
// LoginService handles user login operations
type LoginService interface {
Login(ctx context.Context, input *LoginInput) (*LoginResponse, error)
}
// LoginInput represents the input for user login
type LoginInput struct {
Email string
Password string
}
// LoginResponse represents the response after successful login
type LoginResponse struct {
// User details
UserID string `json:"user_id"`
UserEmail string `json:"user_email"`
UserName string `json:"user_name"`
UserRole string `json:"user_role"`
// Tenant details
TenantID string `json:"tenant_id"`
// Session and tokens
SessionID string `json:"session_id"`
AccessToken string `json:"access_token"`
AccessExpiry time.Time `json:"access_expiry"`
RefreshToken string `json:"refresh_token"`
RefreshExpiry time.Time `json:"refresh_expiry"`
LoginAt time.Time `json:"login_at"`
}
type loginService struct {
loginUC *gatewayuc.LoginUseCase
sessionService service.SessionService
jwtProvider jwt.Provider
logger *zap.Logger
}
// NewLoginService creates a new login service
func NewLoginService(
loginUC *gatewayuc.LoginUseCase,
sessionService service.SessionService,
jwtProvider jwt.Provider,
logger *zap.Logger,
) LoginService {
return &loginService{
loginUC: loginUC,
sessionService: sessionService,
jwtProvider: jwtProvider,
logger: logger.Named("login-service"),
}
}
// Login handles the complete login flow
func (s *loginService) Login(ctx context.Context, input *LoginInput) (*LoginResponse, error) {
// CWE-532: Use hashed email to prevent PII in logs
s.logger.Info("processing login request",
logger.EmailHash(input.Email))
// Execute login use case (validates credentials)
loginOutput, err := s.loginUC.Execute(ctx, &gatewayuc.LoginInput{
Email: input.Email,
Password: input.Password,
})
if err != nil {
s.logger.Error("login failed", zap.Error(err))
return nil, err
}
// CWE-532: Use hashed email to prevent PII in logs
s.logger.Info("credentials validated successfully",
zap.String("user_id", loginOutput.UserID),
logger.EmailHash(loginOutput.UserEmail),
zap.String("tenant_id", loginOutput.TenantID))
// Parse tenant ID to UUID
tenantUUID, err := uuid.Parse(loginOutput.TenantID)
if err != nil {
s.logger.Error("failed to parse tenant ID", zap.Error(err))
return nil, err
}
// Parse user ID to UUID
userUUID, err := uuid.Parse(loginOutput.UserID)
if err != nil {
s.logger.Error("failed to parse user ID", zap.Error(err))
return nil, err
}
// CWE-384: Invalidate all existing sessions before creating new one (Session Fixation Prevention)
// This ensures that any session IDs an attacker may have obtained are invalidated
s.logger.Info("invalidating existing sessions for security",
zap.String("user_uuid", userUUID.String()))
if err := s.sessionService.InvalidateUserSessions(ctx, userUUID); err != nil {
// Log warning but don't fail login - this is best effort cleanup
s.logger.Warn("failed to invalidate existing sessions (non-fatal)",
zap.String("user_uuid", userUUID.String()),
zap.Error(err))
}
// Create new session in two-tier cache
session, err := s.sessionService.CreateSession(
ctx,
0, // UserID as uint64 - not used in our UUID-based system
userUUID,
loginOutput.UserEmail,
loginOutput.UserName,
loginOutput.UserRole,
tenantUUID,
)
if err != nil {
s.logger.Error("failed to create session", zap.Error(err))
return nil, err
}
s.logger.Info("session created", zap.String("session_id", session.ID))
// Generate JWT access and refresh tokens
accessToken, accessExpiry, refreshToken, refreshExpiry, err := s.jwtProvider.GenerateTokenPair(
session.ID,
AccessTokenDuration,
RefreshTokenDuration,
)
if err != nil {
s.logger.Error("failed to generate tokens", zap.Error(err))
// Clean up session
_ = s.sessionService.DeleteSession(ctx, session.ID)
return nil, err
}
s.logger.Info("login completed successfully",
zap.String("user_id", loginOutput.UserID),
zap.String("tenant_id", loginOutput.TenantID),
zap.String("session_id", session.ID))
return &LoginResponse{
UserID: loginOutput.UserID,
UserEmail: loginOutput.UserEmail,
UserName: loginOutput.UserName,
UserRole: loginOutput.UserRole,
TenantID: loginOutput.TenantID,
SessionID: session.ID,
AccessToken: accessToken,
AccessExpiry: accessExpiry,
RefreshToken: refreshToken,
RefreshExpiry: refreshExpiry,
LoginAt: time.Now().UTC(),
}, nil
}

View file

@ -0,0 +1,70 @@
package gateway
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service"
gatewayuc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/gateway"
tenantuc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/tenant"
userusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/distributedmutex"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/jwt"
)
// ProvideRegisterService creates a new RegisterService for dependency injection
func ProvideRegisterService(
validateInputUC *gatewayuc.ValidateRegistrationInputUseCase,
checkTenantSlugUC *gatewayuc.CheckTenantSlugAvailabilityUseCase,
checkPasswordBreachUC *gatewayuc.CheckPasswordBreachUseCase,
hashPasswordUC *gatewayuc.HashPasswordUseCase,
validateTenantSlugUC *tenantuc.ValidateTenantSlugUniqueUseCase,
createTenantEntityUC *tenantuc.CreateTenantEntityUseCase,
saveTenantToRepoUC *tenantuc.SaveTenantToRepoUseCase,
validateUserEmailUC *userusecase.ValidateUserEmailUniqueUseCase,
createUserEntityUC *userusecase.CreateUserEntityUseCase,
saveUserToRepoUC *userusecase.SaveUserToRepoUseCase,
deleteTenantUC *tenantuc.DeleteTenantUseCase,
deleteUserUC *userusecase.DeleteUserUseCase,
distributedMutex distributedmutex.Adapter,
sessionService service.SessionService,
jwtProvider jwt.Provider,
logger *zap.Logger,
) RegisterService {
return NewRegisterService(
validateInputUC,
checkTenantSlugUC,
checkPasswordBreachUC,
hashPasswordUC,
validateTenantSlugUC,
createTenantEntityUC,
saveTenantToRepoUC,
validateUserEmailUC,
createUserEntityUC,
saveUserToRepoUC,
deleteTenantUC,
deleteUserUC,
distributedMutex,
sessionService,
jwtProvider,
logger,
)
}
// ProvideLoginService creates a new LoginService for dependency injection
func ProvideLoginService(
loginUC *gatewayuc.LoginUseCase,
sessionService service.SessionService,
jwtProvider jwt.Provider,
logger *zap.Logger,
) LoginService {
return NewLoginService(loginUC, sessionService, jwtProvider, logger)
}
// ProvideRefreshTokenService creates a new RefreshTokenService for dependency injection
func ProvideRefreshTokenService(
sessionService service.SessionService,
jwtProvider jwt.Provider,
logger *zap.Logger,
) RefreshTokenService {
return NewRefreshTokenService(sessionService, jwtProvider, logger)
}

View file

@ -0,0 +1,123 @@
package gateway
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/jwt"
)
// RefreshTokenService handles token refresh operations
type RefreshTokenService interface {
RefreshToken(ctx context.Context, input *RefreshTokenInput) (*RefreshTokenResponse, error)
}
// RefreshTokenInput represents the input for token refresh
type RefreshTokenInput struct {
RefreshToken string
}
// RefreshTokenResponse represents the response after successful token refresh
type RefreshTokenResponse struct {
// User details
UserID string `json:"user_id"`
UserEmail string `json:"user_email"`
UserName string `json:"user_name"`
UserRole string `json:"user_role"`
// Tenant details
TenantID string `json:"tenant_id"`
// Session and new tokens
SessionID string `json:"session_id"`
AccessToken string `json:"access_token"`
AccessExpiry time.Time `json:"access_expiry"`
RefreshToken string `json:"refresh_token"`
RefreshExpiry time.Time `json:"refresh_expiry"`
RefreshedAt time.Time `json:"refreshed_at"`
}
type refreshTokenService struct {
sessionService service.SessionService
jwtProvider jwt.Provider
logger *zap.Logger
}
// NewRefreshTokenService creates a new refresh token service
func NewRefreshTokenService(
sessionService service.SessionService,
jwtProvider jwt.Provider,
logger *zap.Logger,
) RefreshTokenService {
return &refreshTokenService{
sessionService: sessionService,
jwtProvider: jwtProvider,
logger: logger.Named("refresh-token-service"),
}
}
// RefreshToken validates the refresh token and generates new access/refresh tokens
// CWE-613: Validates session still exists before issuing new tokens
func (s *refreshTokenService) RefreshToken(ctx context.Context, input *RefreshTokenInput) (*RefreshTokenResponse, error) {
s.logger.Info("processing token refresh request")
// Validate the refresh token and extract session ID
sessionID, err := s.jwtProvider.ValidateToken(input.RefreshToken)
if err != nil {
s.logger.Warn("invalid refresh token", zap.Error(err))
return nil, fmt.Errorf("invalid or expired refresh token")
}
s.logger.Debug("refresh token validated", zap.String("session_id", sessionID))
// Retrieve the session to ensure it still exists
// CWE-613: This prevents using a refresh token after logout/session deletion
session, err := s.sessionService.GetSession(ctx, sessionID)
if err != nil {
s.logger.Warn("session not found or expired",
zap.String("session_id", sessionID),
zap.Error(err))
return nil, fmt.Errorf("session not found or expired")
}
s.logger.Info("session retrieved for token refresh",
zap.String("session_id", sessionID),
zap.String("user_id", session.UserUUID.String()),
zap.String("tenant_id", session.TenantID.String()))
// Generate new JWT access and refresh tokens
// Both tokens are regenerated to maintain rotation best practices
accessToken, accessExpiry, refreshToken, refreshExpiry, err := s.jwtProvider.GenerateTokenPair(
session.ID,
AccessTokenDuration,
RefreshTokenDuration,
)
if err != nil {
s.logger.Error("failed to generate new token pair", zap.Error(err))
return nil, fmt.Errorf("failed to generate new tokens")
}
s.logger.Info("token refresh completed successfully",
zap.String("user_id", session.UserUUID.String()),
zap.String("tenant_id", session.TenantID.String()),
zap.String("session_id", session.ID))
return &RefreshTokenResponse{
UserID: session.UserUUID.String(),
UserEmail: session.UserEmail,
UserName: session.UserName,
UserRole: session.UserRole,
TenantID: session.TenantID.String(),
SessionID: session.ID,
AccessToken: accessToken,
AccessExpiry: accessExpiry,
RefreshToken: refreshToken,
RefreshExpiry: refreshExpiry,
RefreshedAt: time.Now().UTC(),
}, nil
}

View file

@ -0,0 +1,389 @@
package gateway
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service"
gatewayuc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/gateway"
tenantuc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/tenant"
userusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/distributedmutex"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/jwt"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/transaction"
)
const (
// Role constants for the three-tier role system (numeric values)
RoleExecutive int = 1 // Can access ANY tenant ANYTIME (root/SaaS owner)
RoleManager int = 2 // User who registered and created tenant (can create users)
RoleStaff int = 3 // User created by manager (cannot create users/tenants)
// Role names for display/API responses
RoleExecutiveName = "executive"
RoleManagerName = "manager"
RoleStaffName = "staff"
// AccessTokenDuration is the lifetime of an access token
AccessTokenDuration = 15 * time.Minute
// RefreshTokenDuration is the lifetime of a refresh token
RefreshTokenDuration = 7 * 24 * time.Hour // 7 days
)
// RegisterService handles user registration operations
type RegisterService interface {
Register(ctx context.Context, input *RegisterInput) (*RegisterResponse, error)
}
// RegisterInput represents the input for user registration
// This is an alias to the usecase layer type for backward compatibility
type RegisterInput = gatewayuc.RegisterInput
// RegisterResponse represents the response after successful registration
type RegisterResponse struct {
// User details
UserID string `json:"user_id"`
UserEmail string `json:"user_email"`
UserName string `json:"user_name"`
UserRole string `json:"user_role"`
// Tenant details
TenantID string `json:"tenant_id"`
TenantName string `json:"tenant_name"`
TenantSlug string `json:"tenant_slug"`
// Session and tokens
SessionID string `json:"session_id"`
AccessToken string `json:"access_token"`
AccessExpiry time.Time `json:"access_expiry"`
RefreshToken string `json:"refresh_token"`
RefreshExpiry time.Time `json:"refresh_expiry"`
CreatedAt time.Time `json:"created_at"`
}
type registerService struct {
// Focused usecases for validation and creation
validateInputUC *gatewayuc.ValidateRegistrationInputUseCase
checkTenantSlugUC *gatewayuc.CheckTenantSlugAvailabilityUseCase
checkPasswordBreachUC *gatewayuc.CheckPasswordBreachUseCase // CWE-521: Password breach checking
hashPasswordUC *gatewayuc.HashPasswordUseCase
// Tenant creation - focused usecases following Clean Architecture
validateTenantSlugUC *tenantuc.ValidateTenantSlugUniqueUseCase
createTenantEntityUC *tenantuc.CreateTenantEntityUseCase
saveTenantToRepoUC *tenantuc.SaveTenantToRepoUseCase
// User creation - focused usecases following Clean Architecture
validateUserEmailUC *userusecase.ValidateUserEmailUniqueUseCase
createUserEntityUC *userusecase.CreateUserEntityUseCase
saveUserToRepoUC *userusecase.SaveUserToRepoUseCase
// Deletion usecases for compensation (SAGA pattern)
deleteTenantUC *tenantuc.DeleteTenantUseCase
deleteUserUC *userusecase.DeleteUserUseCase
// Distributed mutex for preventing race conditions (CWE-664)
distributedMutex distributedmutex.Adapter
// Session and token management
sessionService service.SessionService
jwtProvider jwt.Provider
logger *zap.Logger
}
// NewRegisterService creates a new register service
func NewRegisterService(
validateInputUC *gatewayuc.ValidateRegistrationInputUseCase,
checkTenantSlugUC *gatewayuc.CheckTenantSlugAvailabilityUseCase,
checkPasswordBreachUC *gatewayuc.CheckPasswordBreachUseCase,
hashPasswordUC *gatewayuc.HashPasswordUseCase,
validateTenantSlugUC *tenantuc.ValidateTenantSlugUniqueUseCase,
createTenantEntityUC *tenantuc.CreateTenantEntityUseCase,
saveTenantToRepoUC *tenantuc.SaveTenantToRepoUseCase,
validateUserEmailUC *userusecase.ValidateUserEmailUniqueUseCase,
createUserEntityUC *userusecase.CreateUserEntityUseCase,
saveUserToRepoUC *userusecase.SaveUserToRepoUseCase,
deleteTenantUC *tenantuc.DeleteTenantUseCase,
deleteUserUC *userusecase.DeleteUserUseCase,
distributedMutex distributedmutex.Adapter,
sessionService service.SessionService,
jwtProvider jwt.Provider,
logger *zap.Logger,
) RegisterService {
return &registerService{
validateInputUC: validateInputUC,
checkTenantSlugUC: checkTenantSlugUC,
checkPasswordBreachUC: checkPasswordBreachUC,
hashPasswordUC: hashPasswordUC,
validateTenantSlugUC: validateTenantSlugUC,
createTenantEntityUC: createTenantEntityUC,
saveTenantToRepoUC: saveTenantToRepoUC,
validateUserEmailUC: validateUserEmailUC,
createUserEntityUC: createUserEntityUC,
saveUserToRepoUC: saveUserToRepoUC,
deleteTenantUC: deleteTenantUC,
deleteUserUC: deleteUserUC,
distributedMutex: distributedMutex,
sessionService: sessionService,
jwtProvider: jwtProvider,
logger: logger.Named("register-service"),
}
}
// Register handles the complete registration flow with SAGA pattern
// Orchestrates: validation → tenant creation → user creation → session → tokens
// Uses SAGA for automatic rollback if any database operation fails
func (s *registerService) Register(ctx context.Context, input *RegisterInput) (*RegisterResponse, error) {
// CWE-532: Log with redacted sensitive information
s.logger.Info("registering new user",
logger.EmailHash(input.Email),
logger.TenantSlugHash(input.TenantSlug))
// Create SAGA for this registration workflow
saga := transaction.NewSaga("user-registration", s.logger)
// Step 1: Validate input (no DB writes, no compensation needed)
validateInput := &gatewayuc.RegisterInput{
Email: input.Email,
Password: input.Password,
FirstName: input.FirstName,
LastName: input.LastName,
TenantName: input.TenantName,
TenantSlug: input.TenantSlug,
Timezone: input.Timezone,
// Consent fields
AgreeTermsOfService: input.AgreeTermsOfService,
AgreePromotions: input.AgreePromotions,
AgreeToTrackingAcrossThirdPartyAppsAndServices: input.AgreeToTrackingAcrossThirdPartyAppsAndServices,
// IP address for audit trail
CreatedFromIPAddress: input.CreatedFromIPAddress,
}
if err := s.validateInputUC.Execute(validateInput); err != nil {
s.logger.Error("input validation failed", zap.Error(err))
return nil, err
}
// Step 2: Acquire distributed lock on tenant slug to prevent race conditions (CWE-664, CWE-755)
// This prevents multiple concurrent registrations from creating duplicate tenants
// with the same slug during the window between slug check and tenant creation
lockKey := fmt.Sprintf("registration:tenant-slug:%s", input.TenantSlug)
s.logger.Debug("acquiring distributed lock for tenant slug",
zap.String("lock_key", lockKey))
// CWE-755: Proper error handling - fail registration if lock cannot be obtained
if err := s.distributedMutex.Acquire(ctx, lockKey); err != nil {
s.logger.Error("failed to acquire registration lock",
zap.Error(err),
zap.String("tenant_slug", input.TenantSlug),
zap.String("lock_key", lockKey))
return nil, fmt.Errorf("registration temporarily unavailable, please try again later: %w", err)
}
defer func() {
// Always release the lock when we're done, even if registration fails
s.logger.Debug("releasing distributed lock for tenant slug",
zap.String("lock_key", lockKey))
if err := s.distributedMutex.Release(ctx, lockKey); err != nil {
// Log error but don't fail registration if already completed
s.logger.Error("failed to release lock after registration",
zap.Error(err),
zap.String("lock_key", lockKey))
}
}()
s.logger.Debug("distributed lock acquired successfully",
zap.String("lock_key", lockKey))
// Step 3: Check if tenant slug is available (now protected by lock)
// Even if another request checked at the same time, only one can proceed
if err := s.checkTenantSlugUC.Execute(ctx, input.TenantSlug); err != nil {
s.logger.Error("tenant slug check failed", zap.Error(err))
return nil, err
}
// Step 4: Check if password has been breached (CWE-521: Password Breach Checking)
// This prevents users from using passwords found in known data breaches
if err := s.checkPasswordBreachUC.Execute(ctx, input.Password); err != nil {
s.logger.Error("password breach check failed", zap.Error(err))
return nil, err
}
// Step 5: Validate and hash password (no DB writes, no compensation needed)
passwordHash, err := s.hashPasswordUC.Execute(input.Password)
if err != nil {
s.logger.Error("password hashing failed", zap.Error(err))
return nil, err
}
// Step 6: Create tenant (FIRST DB WRITE - compensation required from here on)
// Using focused use cases following Clean Architecture pattern
// Step 6a: Validate tenant slug uniqueness
if err := s.validateTenantSlugUC.Execute(ctx, input.TenantSlug); err != nil {
s.logger.Error("tenant slug validation failed", zap.Error(err))
return nil, err
}
// Step 6b: Create tenant entity with IP address
tenant, err := s.createTenantEntityUC.Execute(&tenantuc.CreateTenantInput{
Name: input.TenantName,
Slug: input.TenantSlug,
CreatedFromIPAddress: input.CreatedFromIPAddress,
})
if err != nil {
s.logger.Error("tenant entity creation failed", zap.Error(err))
return nil, err
}
// Step 6c: Save tenant to repository
if err := s.saveTenantToRepoUC.Execute(ctx, tenant); err != nil {
s.logger.Error("failed to save tenant", zap.Error(err))
return nil, err
}
s.logger.Info("tenant created successfully",
zap.String("tenant_id", tenant.ID),
zap.String("tenant_slug", tenant.Slug))
// Register compensation: if user creation fails, delete this tenant
saga.AddCompensation(func(ctx context.Context) error {
s.logger.Warn("compensating: deleting tenant due to user creation failure",
zap.String("tenant_id", tenant.ID))
return s.deleteTenantUC.Execute(ctx, tenant.ID)
})
// Step 7: Create user with hashed password (SECOND DB WRITE)
// Using focused use cases following Clean Architecture pattern
// Step 7a: Validate email uniqueness
if err := s.validateUserEmailUC.Execute(ctx, tenant.ID, input.Email); err != nil {
s.logger.Error("user email validation failed - executing compensating transactions",
zap.String("tenant_id", tenant.ID),
zap.Error(err))
saga.Rollback(ctx)
return nil, err
}
// Step 7b: Create user entity
user, err := s.createUserEntityUC.Execute(tenant.ID, &userusecase.CreateUserInput{
Email: input.Email,
FirstName: input.FirstName,
LastName: input.LastName,
PasswordHash: passwordHash,
PasswordHashAlgorithm: "argon2id", // Set the algorithm used
Role: RoleManager,
Timezone: input.Timezone,
// Consent fields
AgreeTermsOfService: input.AgreeTermsOfService,
AgreePromotions: input.AgreePromotions,
AgreeToTrackingAcrossThirdPartyAppsAndServices: input.AgreeToTrackingAcrossThirdPartyAppsAndServices,
// IP address for audit trail
CreatedFromIPAddress: input.CreatedFromIPAddress,
})
if err != nil {
s.logger.Error("user entity creation failed - executing compensating transactions",
zap.String("tenant_id", tenant.ID),
zap.Error(err))
saga.Rollback(ctx)
return nil, err
}
// Step 7c: Save user to repository
if err := s.saveUserToRepoUC.Execute(ctx, tenant.ID, user); err != nil {
s.logger.Error("failed to save user - executing compensating transactions",
zap.String("tenant_id", tenant.ID),
zap.String("user_id", user.ID),
zap.Error(err))
saga.Rollback(ctx)
return nil, err
}
s.logger.Info("user created successfully",
zap.String("user_id", user.ID),
zap.String("tenant_id", tenant.ID))
// Step 8: Parse UUIDs for session creation
tenantUUID, err := uuid.Parse(tenant.ID)
if err != nil {
s.logger.Error("failed to parse tenant ID", zap.Error(err))
// Rollback tenant and user
saga.Rollback(ctx)
return nil, err
}
userUUID, err := uuid.Parse(user.ID)
if err != nil {
s.logger.Error("failed to parse user ID", zap.Error(err))
// Rollback tenant and user
saga.Rollback(ctx)
return nil, err
}
// Step 9: Create session in two-tier cache
// Note: Session.UserID expects uint64, but we're using UUIDs
// We'll use 0 for now and rely on UserUUID
session, err := s.sessionService.CreateSession(
ctx,
0, // UserID as uint64 - not used in our UUID-based system
userUUID,
user.Email,
user.FullName(),
RoleManagerName, // Pass string name for session
tenantUUID,
)
if err != nil {
s.logger.Error("failed to create session", zap.Error(err))
// Rollback tenant and user
saga.Rollback(ctx)
return nil, err
}
s.logger.Info("session created", zap.String("session_id", session.ID))
// Step 10: Generate JWT access and refresh tokens
accessToken, accessExpiry, refreshToken, refreshExpiry, err := s.jwtProvider.GenerateTokenPair(
session.ID,
AccessTokenDuration,
RefreshTokenDuration,
)
if err != nil {
s.logger.Error("failed to generate tokens", zap.Error(err))
// Clean up session
_ = s.sessionService.DeleteSession(ctx, session.ID)
// Rollback tenant and user
saga.Rollback(ctx)
return nil, err
}
// Success! Registration completed, distributed lock will be released by defer
s.logger.Info("registration completed successfully",
zap.String("user_id", user.ID),
zap.String("tenant_id", tenant.ID),
zap.String("session_id", session.ID))
return &RegisterResponse{
UserID: user.ID,
UserEmail: user.Email,
UserName: user.FullName(),
UserRole: RoleManagerName, // Return string name for API response
TenantID: tenant.ID,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
SessionID: session.ID,
AccessToken: accessToken,
AccessExpiry: accessExpiry,
RefreshToken: refreshToken,
RefreshExpiry: refreshExpiry,
CreatedAt: user.CreatedAt,
}, nil
}

View file

@ -0,0 +1,408 @@
package ipcleanup
import (
"context"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
domainpage "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/page"
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
domaintenant "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/tenant"
domainuser "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/ipcrypt"
)
// CleanupService handles cleanup of expired IP addresses for GDPR compliance
// CWE-359: IP addresses must be deleted after 90 days (Option 2: Clear both IP and timestamp)
type CleanupService struct {
userRepo domainuser.Repository
tenantRepo domaintenant.Repository
siteRepo domainsite.Repository
pageRepo domainpage.Repository
ipEncryptor *ipcrypt.IPEncryptor
logger *zap.Logger
}
// ProvideCleanupService creates a new CleanupService
func ProvideCleanupService(
userRepo domainuser.Repository,
tenantRepo domaintenant.Repository,
siteRepo domainsite.Repository,
pageRepo domainpage.Repository,
ipEncryptor *ipcrypt.IPEncryptor,
logger *zap.Logger,
) *CleanupService {
return &CleanupService{
userRepo: userRepo,
tenantRepo: tenantRepo,
siteRepo: siteRepo,
pageRepo: pageRepo,
ipEncryptor: ipEncryptor,
logger: logger.Named("ip-cleanup-service"),
}
}
// CleanupExpiredIPs removes IP addresses older than 90 days for GDPR compliance
// Option 2: Clears BOTH IP address AND timestamp (complete removal)
// This method should be called by a scheduled job
func (s *CleanupService) CleanupExpiredIPs(ctx context.Context) error {
s.logger.Info("starting IP address cleanup for GDPR compliance (Option 2: Clear both IP and timestamp)")
// Calculate the date 90 days ago
now := time.Now()
expirationDate := now.AddDate(0, 0, -90)
s.logger.Info("cleaning up IP addresses older than 90 days",
zap.Time("expiration_date", expirationDate),
zap.Int("retention_days", 90))
var totalCleaned int
var errors []error
// Clean up each entity type
usersCleaned, err := s.cleanupUserIPs(ctx, expirationDate)
if err != nil {
s.logger.Error("failed to cleanup user IPs", zap.Error(err))
errors = append(errors, err)
}
totalCleaned += usersCleaned
tenantsCleaned, err := s.cleanupTenantIPs(ctx, expirationDate)
if err != nil {
s.logger.Error("failed to cleanup tenant IPs", zap.Error(err))
errors = append(errors, err)
}
totalCleaned += tenantsCleaned
sitesCleaned, err := s.cleanupSiteIPs(ctx, expirationDate)
if err != nil {
s.logger.Error("failed to cleanup site IPs", zap.Error(err))
errors = append(errors, err)
}
totalCleaned += sitesCleaned
pagesCleaned, err := s.cleanupPageIPs(ctx, expirationDate)
if err != nil {
s.logger.Error("failed to cleanup page IPs", zap.Error(err))
errors = append(errors, err)
}
totalCleaned += pagesCleaned
if len(errors) > 0 {
s.logger.Warn("IP cleanup completed with errors",
zap.Int("total_cleaned", totalCleaned),
zap.Int("error_count", len(errors)))
return errors[0] // Return first error
}
s.logger.Info("IP cleanup completed successfully",
zap.Int("total_records_cleaned", totalCleaned),
zap.Int("users", usersCleaned),
zap.Int("tenants", tenantsCleaned),
zap.Int("sites", sitesCleaned),
zap.Int("pages", pagesCleaned))
return nil
}
// cleanupUserIPs cleans up expired IP addresses from User entities
func (s *CleanupService) cleanupUserIPs(ctx context.Context, expirationDate time.Time) (int, error) {
s.logger.Info("cleaning up user IP addresses")
// Note: This implementation uses ListByDate to query users in batches
// For large datasets, consider implementing a background job that processes smaller chunks
// Calculate date range: from beginning of time to 90 days ago
startDate := "1970-01-01"
endDate := expirationDate.Format("2006-01-02")
totalCleaned := 0
// Note: Users are tenant-scoped, so we would need to iterate through tenants
// For now, we'll log a warning about this limitation
s.logger.Warn("user IP cleanup requires tenant iteration - this is a simplified implementation",
zap.String("start_date", startDate),
zap.String("end_date", endDate))
// TODO: Implement tenant iteration
// Example approach:
// 1. Get list of all tenants
// 2. For each tenant, query users by date
// 3. Process each user
s.logger.Info("user IP cleanup skipped (requires tenant iteration support)",
zap.Int("cleaned", totalCleaned))
return totalCleaned, nil
}
// cleanupTenantIPs cleans up expired IP addresses from Tenant entities
func (s *CleanupService) cleanupTenantIPs(ctx context.Context, expirationDate time.Time) (int, error) {
s.logger.Info("cleaning up tenant IP addresses")
// List all active tenants (we'll check all statuses to be thorough)
statuses := []domaintenant.Status{
domaintenant.StatusActive,
domaintenant.StatusInactive,
domaintenant.StatusSuspended,
}
totalCleaned := 0
batchSize := 1000 // Process up to 1000 tenants per status
for _, status := range statuses {
tenants, err := s.tenantRepo.ListByStatus(ctx, status, batchSize)
if err != nil {
s.logger.Error("failed to list tenants by status",
zap.String("status", string(status)),
zap.Error(err))
continue
}
s.logger.Debug("processing tenants for IP cleanup",
zap.String("status", string(status)),
zap.Int("count", len(tenants)))
for _, tenant := range tenants {
needsUpdate := false
// Check if created IP timestamp is expired
if !tenant.CreatedFromIPTimestamp.IsZero() && tenant.CreatedFromIPTimestamp.Before(expirationDate) {
tenant.CreatedFromIPAddress = ""
tenant.CreatedFromIPTimestamp = time.Time{} // Zero value
needsUpdate = true
}
// Check if modified IP timestamp is expired
if !tenant.ModifiedFromIPTimestamp.IsZero() && tenant.ModifiedFromIPTimestamp.Before(expirationDate) {
tenant.ModifiedFromIPAddress = ""
tenant.ModifiedFromIPTimestamp = time.Time{} // Zero value
needsUpdate = true
}
if needsUpdate {
if err := s.tenantRepo.Update(ctx, tenant); err != nil {
s.logger.Error("failed to update tenant IP fields",
zap.String("tenant_id", tenant.ID),
zap.Error(err))
continue
}
totalCleaned++
s.logger.Debug("cleared expired IP from tenant",
zap.String("tenant_id", tenant.ID))
}
}
}
s.logger.Info("tenant IP cleanup completed",
zap.Int("cleaned", totalCleaned))
return totalCleaned, nil
}
// cleanupSiteIPs cleans up expired IP addresses from Site entities
func (s *CleanupService) cleanupSiteIPs(ctx context.Context, expirationDate time.Time) (int, error) {
s.logger.Info("cleaning up site IP addresses")
// First, get all tenants so we can iterate through their sites
statuses := []domaintenant.Status{
domaintenant.StatusActive,
domaintenant.StatusInactive,
domaintenant.StatusSuspended,
}
totalCleaned := 0
tenantBatchSize := 1000
siteBatchSize := 100
for _, status := range statuses {
tenants, err := s.tenantRepo.ListByStatus(ctx, status, tenantBatchSize)
if err != nil {
s.logger.Error("failed to list tenants for site cleanup",
zap.String("status", string(status)),
zap.Error(err))
continue
}
// For each tenant, list their sites and clean up expired IPs
for _, tenant := range tenants {
tenantUUID, err := gocql.ParseUUID(tenant.ID)
if err != nil {
s.logger.Error("failed to parse tenant UUID",
zap.String("tenant_id", tenant.ID),
zap.Error(err))
continue
}
// List sites for this tenant (using pagination)
var pageState []byte
for {
sites, nextPageState, err := s.siteRepo.ListByTenant(ctx, tenantUUID, siteBatchSize, pageState)
if err != nil {
s.logger.Error("failed to list sites for tenant",
zap.String("tenant_id", tenant.ID),
zap.Error(err))
break
}
// Process each site
for _, site := range sites {
needsUpdate := false
// Check if created IP timestamp is expired
if !site.CreatedFromIPTimestamp.IsZero() && site.CreatedFromIPTimestamp.Before(expirationDate) {
site.CreatedFromIPAddress = ""
site.CreatedFromIPTimestamp = time.Time{} // Zero value
needsUpdate = true
}
// Check if modified IP timestamp is expired
if !site.ModifiedFromIPTimestamp.IsZero() && site.ModifiedFromIPTimestamp.Before(expirationDate) {
site.ModifiedFromIPAddress = ""
site.ModifiedFromIPTimestamp = time.Time{} // Zero value
needsUpdate = true
}
if needsUpdate {
if err := s.siteRepo.Update(ctx, site); err != nil {
s.logger.Error("failed to update site IP fields",
zap.String("site_id", site.ID.String()),
zap.Error(err))
continue
}
totalCleaned++
s.logger.Debug("cleared expired IP from site",
zap.String("site_id", site.ID.String()))
}
}
// Check if there are more pages
if len(nextPageState) == 0 {
break
}
pageState = nextPageState
}
}
}
s.logger.Info("site IP cleanup completed",
zap.Int("cleaned", totalCleaned))
return totalCleaned, nil
}
// cleanupPageIPs cleans up expired IP addresses from Page entities
func (s *CleanupService) cleanupPageIPs(ctx context.Context, expirationDate time.Time) (int, error) {
s.logger.Info("cleaning up page IP addresses")
// Pages are partitioned by site_id, so we need to:
// 1. Get all tenants
// 2. For each tenant, get all sites
// 3. For each site, get all pages
// This is the most expensive operation due to Cassandra's data model
statuses := []domaintenant.Status{
domaintenant.StatusActive,
domaintenant.StatusInactive,
domaintenant.StatusSuspended,
}
totalCleaned := 0
tenantBatchSize := 1000
siteBatchSize := 100
for _, status := range statuses {
tenants, err := s.tenantRepo.ListByStatus(ctx, status, tenantBatchSize)
if err != nil {
s.logger.Error("failed to list tenants for page cleanup",
zap.String("status", string(status)),
zap.Error(err))
continue
}
// For each tenant, list their sites
for _, tenant := range tenants {
tenantUUID, err := gocql.ParseUUID(tenant.ID)
if err != nil {
s.logger.Error("failed to parse tenant UUID for pages",
zap.String("tenant_id", tenant.ID),
zap.Error(err))
continue
}
// List sites for this tenant
var sitePageState []byte
for {
sites, nextSitePageState, err := s.siteRepo.ListByTenant(ctx, tenantUUID, siteBatchSize, sitePageState)
if err != nil {
s.logger.Error("failed to list sites for page cleanup",
zap.String("tenant_id", tenant.ID),
zap.Error(err))
break
}
// For each site, get all pages
for _, site := range sites {
pages, err := s.pageRepo.GetBySiteID(ctx, site.ID)
if err != nil {
s.logger.Error("failed to get pages for site",
zap.String("site_id", site.ID.String()),
zap.Error(err))
continue
}
// Process each page
for _, page := range pages {
needsUpdate := false
// Check if created IP timestamp is expired
if !page.CreatedFromIPTimestamp.IsZero() && page.CreatedFromIPTimestamp.Before(expirationDate) {
page.CreatedFromIPAddress = ""
page.CreatedFromIPTimestamp = time.Time{} // Zero value
needsUpdate = true
}
// Check if modified IP timestamp is expired
if !page.ModifiedFromIPTimestamp.IsZero() && page.ModifiedFromIPTimestamp.Before(expirationDate) {
page.ModifiedFromIPAddress = ""
page.ModifiedFromIPTimestamp = time.Time{} // Zero value
needsUpdate = true
}
if needsUpdate {
if err := s.pageRepo.Update(ctx, page); err != nil {
s.logger.Error("failed to update page IP fields",
zap.String("page_id", page.PageID),
zap.String("site_id", page.SiteID.String()),
zap.Error(err))
continue
}
totalCleaned++
s.logger.Debug("cleared expired IP from page",
zap.String("page_id", page.PageID),
zap.String("site_id", page.SiteID.String()))
}
}
}
// Check if there are more site pages
if len(nextSitePageState) == 0 {
break
}
sitePageState = nextSitePageState
}
}
}
s.logger.Info("page IP cleanup completed",
zap.Int("cleaned", totalCleaned))
return totalCleaned, nil
}
// ShouldCleanupIP checks if an IP address timestamp has expired
func (s *CleanupService) ShouldCleanupIP(timestamp time.Time) bool {
return s.ipEncryptor.IsExpired(timestamp)
}

View file

@ -0,0 +1,148 @@
package page
import (
"context"
"fmt"
"github.com/gocql/gocql"
"go.uber.org/zap"
pageusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/page"
)
// DeletePagesService handles page deletion operations
type DeletePagesService interface {
DeletePages(ctx context.Context, tenantID, siteID gocql.UUID, input *pageusecase.DeletePagesInput) (*pageusecase.DeletePagesOutput, error)
DeleteAllPages(ctx context.Context, tenantID, siteID gocql.UUID) (*pageusecase.DeletePagesOutput, error)
}
type deletePagesService struct {
// Focused usecases
validateSiteUC *pageusecase.ValidateSiteForDeletionUseCase
deletePagesRepoUC *pageusecase.DeletePagesFromRepoUseCase
deletePagesSearchUC *pageusecase.DeletePagesFromSearchUseCase
logger *zap.Logger
}
// NewDeletePagesService creates a new DeletePagesService
func NewDeletePagesService(
validateSiteUC *pageusecase.ValidateSiteForDeletionUseCase,
deletePagesRepoUC *pageusecase.DeletePagesFromRepoUseCase,
deletePagesSearchUC *pageusecase.DeletePagesFromSearchUseCase,
logger *zap.Logger,
) DeletePagesService {
return &deletePagesService{
validateSiteUC: validateSiteUC,
deletePagesRepoUC: deletePagesRepoUC,
deletePagesSearchUC: deletePagesSearchUC,
logger: logger.Named("delete-pages-service"),
}
}
// DeletePages orchestrates the deletion of specific pages
func (s *deletePagesService) DeletePages(ctx context.Context, tenantID, siteID gocql.UUID, input *pageusecase.DeletePagesInput) (*pageusecase.DeletePagesOutput, error) {
s.logger.Info("deleting pages",
zap.String("tenant_id", tenantID.String()),
zap.String("site_id", siteID.String()),
zap.Int("page_count", len(input.PageIDs)))
// Step 1: Validate site
_, err := s.validateSiteUC.Execute(ctx, tenantID, siteID)
if err != nil {
s.logger.Error("failed to validate site", zap.Error(err))
return nil, err
}
// Step 2: Delete pages from database
deleteResult, err := s.deletePagesRepoUC.Execute(ctx, siteID, input.PageIDs)
if err != nil {
s.logger.Error("failed to delete pages from database", zap.Error(err))
return nil, err
}
// Step 3: Delete pages from search index (only if database delete succeeded)
deindexedCount := 0
if deleteResult.DeletedCount > 0 {
// Only delete pages that were successfully deleted from database
successfulPageIDs := s.getSuccessfulPageIDs(input.PageIDs, deleteResult.FailedPages)
if len(successfulPageIDs) > 0 {
deindexedCount, _ = s.deletePagesSearchUC.Execute(ctx, siteID, successfulPageIDs)
}
}
// Step 4: Build output
message := fmt.Sprintf("Successfully deleted %d pages from database, removed %d from search index",
deleteResult.DeletedCount, deindexedCount)
if len(deleteResult.FailedPages) > 0 {
message += fmt.Sprintf(", failed %d pages", len(deleteResult.FailedPages))
}
s.logger.Info("pages deleted successfully",
zap.String("site_id", siteID.String()),
zap.Int("deleted", deleteResult.DeletedCount),
zap.Int("deindexed", deindexedCount),
zap.Int("failed", len(deleteResult.FailedPages)))
return &pageusecase.DeletePagesOutput{
DeletedCount: deleteResult.DeletedCount,
DeindexedCount: deindexedCount,
FailedPages: deleteResult.FailedPages,
Message: message,
}, nil
}
// DeleteAllPages orchestrates the deletion of all pages for a site
func (s *deletePagesService) DeleteAllPages(ctx context.Context, tenantID, siteID gocql.UUID) (*pageusecase.DeletePagesOutput, error) {
s.logger.Info("deleting all pages",
zap.String("tenant_id", tenantID.String()),
zap.String("site_id", siteID.String()))
// Step 1: Validate site
_, err := s.validateSiteUC.Execute(ctx, tenantID, siteID)
if err != nil {
s.logger.Error("failed to validate site", zap.Error(err))
return nil, err
}
// Step 2: Delete all pages from database
count, err := s.deletePagesRepoUC.ExecuteDeleteAll(ctx, siteID)
if err != nil {
s.logger.Error("failed to delete all pages from database", zap.Error(err))
return nil, err
}
// Step 3: Delete all documents from search index
_ = s.deletePagesSearchUC.ExecuteDeleteAll(ctx, siteID)
s.logger.Info("all pages deleted successfully",
zap.String("site_id", siteID.String()),
zap.Int64("count", count))
return &pageusecase.DeletePagesOutput{
DeletedCount: int(count),
DeindexedCount: int(count),
Message: fmt.Sprintf("Successfully deleted all %d pages", count),
}, nil
}
// Helper: Get list of page IDs that were successfully deleted (exclude failed ones)
func (s *deletePagesService) getSuccessfulPageIDs(allPageIDs, failedPageIDs []string) []string {
if len(failedPageIDs) == 0 {
return allPageIDs
}
failedMap := make(map[string]bool, len(failedPageIDs))
for _, id := range failedPageIDs {
failedMap[id] = true
}
successful := make([]string, 0, len(allPageIDs)-len(failedPageIDs))
for _, id := range allPageIDs {
if !failedMap[id] {
successful = append(successful, id)
}
}
return successful
}

View file

@ -0,0 +1,80 @@
package page
import (
"context"
"github.com/gocql/gocql"
"go.uber.org/zap"
pageusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/page"
)
// SearchPagesService handles page search operations
type SearchPagesService interface {
SearchPages(ctx context.Context, tenantID, siteID gocql.UUID, input *pageusecase.SearchPagesInput) (*pageusecase.SearchPagesOutput, error)
}
type searchPagesService struct {
// Focused usecases
validateSiteUC *pageusecase.ValidateSiteForSearchUseCase
executeSearchUC *pageusecase.ExecuteSearchQueryUseCase
incrementCountUC *pageusecase.IncrementSearchCountUseCase
logger *zap.Logger
}
// NewSearchPagesService creates a new SearchPagesService
func NewSearchPagesService(
validateSiteUC *pageusecase.ValidateSiteForSearchUseCase,
executeSearchUC *pageusecase.ExecuteSearchQueryUseCase,
incrementCountUC *pageusecase.IncrementSearchCountUseCase,
logger *zap.Logger,
) SearchPagesService {
return &searchPagesService{
validateSiteUC: validateSiteUC,
executeSearchUC: executeSearchUC,
incrementCountUC: incrementCountUC,
logger: logger.Named("search-pages-service"),
}
}
// SearchPages orchestrates the page search workflow
func (s *searchPagesService) SearchPages(ctx context.Context, tenantID, siteID gocql.UUID, input *pageusecase.SearchPagesInput) (*pageusecase.SearchPagesOutput, error) {
s.logger.Info("searching pages",
zap.String("tenant_id", tenantID.String()),
zap.String("site_id", siteID.String()),
zap.String("query", input.Query))
// Step 1: Validate site (no quota check - usage-based billing)
site, err := s.validateSiteUC.Execute(ctx, tenantID, siteID)
if err != nil {
s.logger.Error("failed to validate site", zap.Error(err))
return nil, err
}
// Step 2: Execute search query
result, err := s.executeSearchUC.Execute(ctx, siteID, input.Query, input.Limit, input.Offset, input.Filter)
if err != nil {
s.logger.Error("failed to execute search", zap.Error(err))
return nil, err
}
// Step 3: Increment search count (for billing tracking)
if err := s.incrementCountUC.Execute(ctx, site); err != nil {
s.logger.Warn("failed to increment search count (non-fatal)", zap.Error(err))
// Don't fail the search operation
}
s.logger.Info("pages searched successfully",
zap.String("site_id", siteID.String()),
zap.Int64("total_hits", result.TotalHits))
return &pageusecase.SearchPagesOutput{
Hits: result.Hits,
Query: result.Query,
ProcessingTimeMs: result.ProcessingTimeMs,
TotalHits: result.TotalHits,
Limit: result.Limit,
Offset: result.Offset,
}, nil
}

View file

@ -0,0 +1,133 @@
package page
import (
"context"
"github.com/gocql/gocql"
"go.uber.org/zap"
pageusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/page"
)
// SyncStatusService handles sync status operations
type SyncStatusService interface {
GetSyncStatus(ctx context.Context, tenantID, siteID gocql.UUID) (*pageusecase.SyncStatusOutput, error)
GetPageDetails(ctx context.Context, tenantID, siteID gocql.UUID, input *pageusecase.GetPageDetailsInput) (*pageusecase.PageDetailsOutput, error)
}
type syncStatusService struct {
// Focused usecases
validateSiteUC *pageusecase.ValidateSiteForStatusUseCase
getStatsUC *pageusecase.GetPageStatisticsUseCase
getIndexStatusUC *pageusecase.GetSearchIndexStatusUseCase
getPageByIDUC *pageusecase.GetPageByIDUseCase
logger *zap.Logger
}
// NewSyncStatusService creates a new SyncStatusService
func NewSyncStatusService(
validateSiteUC *pageusecase.ValidateSiteForStatusUseCase,
getStatsUC *pageusecase.GetPageStatisticsUseCase,
getIndexStatusUC *pageusecase.GetSearchIndexStatusUseCase,
getPageByIDUC *pageusecase.GetPageByIDUseCase,
logger *zap.Logger,
) SyncStatusService {
return &syncStatusService{
validateSiteUC: validateSiteUC,
getStatsUC: getStatsUC,
getIndexStatusUC: getIndexStatusUC,
getPageByIDUC: getPageByIDUC,
logger: logger.Named("sync-status-service"),
}
}
// GetSyncStatus orchestrates retrieving sync status for a site
func (s *syncStatusService) GetSyncStatus(ctx context.Context, tenantID, siteID gocql.UUID) (*pageusecase.SyncStatusOutput, error) {
s.logger.Info("getting sync status",
zap.String("tenant_id", tenantID.String()),
zap.String("site_id", siteID.String()))
// Step 1: Validate site
site, err := s.validateSiteUC.Execute(ctx, tenantID, siteID)
if err != nil {
s.logger.Error("failed to validate site", zap.Error(err))
return nil, err
}
// Step 2: Get page statistics
stats, err := s.getStatsUC.Execute(ctx, siteID)
if err != nil {
s.logger.Error("failed to get page statistics", zap.Error(err))
return nil, err
}
// Step 3: Get search index status
indexStatus, err := s.getIndexStatusUC.Execute(ctx, siteID)
if err != nil {
s.logger.Error("failed to get search index status", zap.Error(err))
return nil, err
}
s.logger.Info("sync status retrieved successfully",
zap.String("site_id", siteID.String()),
zap.Int64("total_pages", stats.TotalPages))
// Step 4: Build output
return &pageusecase.SyncStatusOutput{
SiteID: siteID.String(),
TotalPages: stats.TotalPages,
PublishedPages: stats.PublishedPages,
DraftPages: stats.DraftPages,
LastSyncedAt: site.LastIndexedAt,
PagesIndexedMonth: site.MonthlyPagesIndexed,
SearchRequestsMonth: site.SearchRequestsCount,
LastResetAt: site.LastResetAt,
SearchIndexStatus: indexStatus.Status,
SearchIndexDocCount: indexStatus.DocumentCount,
}, nil
}
// GetPageDetails orchestrates retrieving details for a specific page
func (s *syncStatusService) GetPageDetails(ctx context.Context, tenantID, siteID gocql.UUID, input *pageusecase.GetPageDetailsInput) (*pageusecase.PageDetailsOutput, error) {
s.logger.Info("getting page details",
zap.String("tenant_id", tenantID.String()),
zap.String("site_id", siteID.String()),
zap.String("page_id", input.PageID))
// Step 1: Validate site
_, err := s.validateSiteUC.Execute(ctx, tenantID, siteID)
if err != nil {
s.logger.Error("failed to validate site", zap.Error(err))
return nil, err
}
// Step 2: Get page by ID
page, err := s.getPageByIDUC.Execute(ctx, siteID, input.PageID)
if err != nil {
s.logger.Error("failed to get page", zap.Error(err))
return nil, err
}
s.logger.Info("page details retrieved successfully",
zap.String("site_id", siteID.String()),
zap.String("page_id", input.PageID))
// Step 3: Build output
isIndexed := !page.IndexedAt.IsZero()
return &pageusecase.PageDetailsOutput{
PageID: page.PageID,
Title: page.Title,
Excerpt: page.Excerpt,
URL: page.URL,
Status: page.Status,
PostType: page.PostType,
Author: page.Author,
PublishedAt: page.PublishedAt,
ModifiedAt: page.ModifiedAt,
IndexedAt: page.IndexedAt,
MeilisearchDocID: page.MeilisearchDocID,
IsIndexed: isIndexed,
}, nil
}

View file

@ -0,0 +1,143 @@
package page
import (
"context"
"fmt"
"github.com/gocql/gocql"
"go.uber.org/zap"
domainpage "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/page"
pageusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/page"
)
// SyncPagesService handles page synchronization operations
type SyncPagesService interface {
SyncPages(ctx context.Context, tenantID, siteID gocql.UUID, input *pageusecase.SyncPagesInput) (*pageusecase.SyncPagesOutput, error)
}
type syncPagesService struct {
// Focused usecases
validateSiteUC *pageusecase.ValidateSiteUseCase
ensureIndexUC *pageusecase.EnsureSearchIndexUseCase
createPageUC *pageusecase.CreatePageEntityUseCase
upsertPageUC *pageusecase.UpsertPageUseCase
indexPageUC *pageusecase.IndexPageToSearchUseCase
updateUsageUC *pageusecase.UpdateSiteUsageUseCase
logger *zap.Logger
}
// NewSyncPagesService creates a new SyncPagesService
func NewSyncPagesService(
validateSiteUC *pageusecase.ValidateSiteUseCase,
ensureIndexUC *pageusecase.EnsureSearchIndexUseCase,
createPageUC *pageusecase.CreatePageEntityUseCase,
upsertPageUC *pageusecase.UpsertPageUseCase,
indexPageUC *pageusecase.IndexPageToSearchUseCase,
updateUsageUC *pageusecase.UpdateSiteUsageUseCase,
logger *zap.Logger,
) SyncPagesService {
return &syncPagesService{
validateSiteUC: validateSiteUC,
ensureIndexUC: ensureIndexUC,
createPageUC: createPageUC,
upsertPageUC: upsertPageUC,
indexPageUC: indexPageUC,
updateUsageUC: updateUsageUC,
logger: logger.Named("sync-pages-service"),
}
}
// SyncPages orchestrates the page synchronization workflow
func (s *syncPagesService) SyncPages(ctx context.Context, tenantID, siteID gocql.UUID, input *pageusecase.SyncPagesInput) (*pageusecase.SyncPagesOutput, error) {
s.logger.Info("syncing pages",
zap.String("tenant_id", tenantID.String()),
zap.String("site_id", siteID.String()),
zap.Int("page_count", len(input.Pages)))
// Step 1: Validate site (no quota check - usage-based billing)
site, err := s.validateSiteUC.Execute(ctx, tenantID, siteID)
if err != nil {
s.logger.Error("failed to validate site", zap.Error(err))
return nil, err
}
// Step 2: Ensure search index exists
if err := s.ensureIndexUC.Execute(ctx, siteID); err != nil {
s.logger.Error("failed to ensure search index", zap.Error(err))
return nil, err
}
// Step 3: Process pages (create, save, prepare for indexing)
syncedCount, failedPages, pagesToIndex := s.processPages(ctx, siteID, site.TenantID, input.Pages)
// Step 4: Bulk index pages to search
indexedCount, err := s.indexPageUC.Execute(ctx, siteID, pagesToIndex)
if err != nil {
s.logger.Error("failed to index pages", zap.Error(err))
return nil, err
}
// Step 5: Update site usage tracking (for billing)
if indexedCount > 0 {
if err := s.updateUsageUC.Execute(ctx, site, indexedCount); err != nil {
s.logger.Warn("failed to update usage (non-fatal)", zap.Error(err))
// Don't fail the whole operation
}
}
// Step 6: Build output
message := fmt.Sprintf("Successfully synced %d pages, indexed %d pages", syncedCount, indexedCount)
if len(failedPages) > 0 {
message += fmt.Sprintf(", failed %d pages", len(failedPages))
}
s.logger.Info("pages synced successfully",
zap.String("site_id", siteID.String()),
zap.Int("synced", syncedCount),
zap.Int("indexed", indexedCount),
zap.Int("failed", len(failedPages)))
return &pageusecase.SyncPagesOutput{
SyncedCount: syncedCount,
IndexedCount: indexedCount,
FailedPages: failedPages,
Message: message,
}, nil
}
// Helper: Process pages - create entities, save to DB, collect pages to index
func (s *syncPagesService) processPages(
ctx context.Context,
siteID, tenantID gocql.UUID,
pages []pageusecase.SyncPageInput,
) (int, []string, []*domainpage.Page) {
syncedCount := 0
var failedPages []string
var pagesToIndex []*domainpage.Page
for _, pageInput := range pages {
// Create page entity (usecase)
page, err := s.createPageUC.Execute(siteID, tenantID, pageInput)
if err != nil {
failedPages = append(failedPages, pageInput.PageID)
continue
}
// Save to database (usecase)
if err := s.upsertPageUC.Execute(ctx, page); err != nil {
failedPages = append(failedPages, pageInput.PageID)
continue
}
syncedCount++
// Collect pages that should be indexed
if page.ShouldIndex() {
pagesToIndex = append(pagesToIndex, page)
}
}
return syncedCount, failedPages, pagesToIndex
}

View file

@ -0,0 +1,12 @@
package service
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/cache"
)
// ProvideSessionService provides a session service instance
func ProvideSessionService(cache cache.TwoTierCacher, logger *zap.Logger) SessionService {
return NewSessionService(cache, logger)
}

View file

@ -0,0 +1,177 @@
// File Path: monorepo/cloud/maplepress-backend/internal/service/securityevent/logger.go
package securityevent
import (
"context"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/securityevent"
)
// Logger handles logging of security events
// CWE-778: Ensures sufficient logging of security events for audit and forensics
type Logger interface {
// LogEvent logs a security event
LogEvent(ctx context.Context, event *securityevent.SecurityEvent) error
// LogAccountLocked logs an account lockout event
LogAccountLocked(ctx context.Context, emailHash, clientIP string, failedAttempts int, lockoutDuration string) error
// LogAccountUnlocked logs an account unlock event
LogAccountUnlocked(ctx context.Context, emailHash, unlockedBy string) error
// LogFailedLogin logs a failed login attempt
LogFailedLogin(ctx context.Context, emailHash, clientIP string, remainingAttempts int) error
// LogExcessiveFailedLogin logs excessive failed login attempts
LogExcessiveFailedLogin(ctx context.Context, emailHash, clientIP string, attemptCount int) error
// LogSuccessfulLogin logs a successful login
LogSuccessfulLogin(ctx context.Context, emailHash, clientIP string) error
// LogIPRateLimitExceeded logs IP rate limit exceeded
LogIPRateLimitExceeded(ctx context.Context, clientIP string) error
}
type securityEventLogger struct {
logger *zap.Logger
}
// NewSecurityEventLogger creates a new security event logger
func NewSecurityEventLogger(logger *zap.Logger) Logger {
return &securityEventLogger{
logger: logger.Named("security-events"),
}
}
// ProvideSecurityEventLogger provides a SecurityEventLogger for dependency injection
func ProvideSecurityEventLogger(logger *zap.Logger) Logger {
return NewSecurityEventLogger(logger)
}
// LogEvent logs a security event
func (s *securityEventLogger) LogEvent(ctx context.Context, event *securityevent.SecurityEvent) error {
// Map severity to log level
logFunc := s.logger.Info
switch event.Severity {
case securityevent.SeverityLow:
logFunc = s.logger.Info
case securityevent.SeverityMedium:
logFunc = s.logger.Warn
case securityevent.SeverityHigh, securityevent.SeverityCritical:
logFunc = s.logger.Error
}
// Build log fields
fields := []zap.Field{
zap.String("event_id", event.ID),
zap.String("event_type", string(event.EventType)),
zap.String("severity", string(event.Severity)),
zap.String("email_hash", event.EmailHash),
zap.String("client_ip", event.ClientIP),
zap.Time("timestamp", event.Timestamp),
}
if event.UserAgent != "" {
fields = append(fields, zap.String("user_agent", event.UserAgent))
}
// Add metadata fields
for key, value := range event.Metadata {
fields = append(fields, zap.Any(key, value))
}
logFunc(event.Message, fields...)
// TODO: In production, also persist to a security event database/SIEM
// This could be implemented as a repository pattern:
// - Store in Cassandra for long-term retention
// - Send to SIEM (Splunk, ELK, etc.) for analysis
// - Send to monitoring/alerting system
return nil
}
// LogAccountLocked logs an account lockout event
func (s *securityEventLogger) LogAccountLocked(ctx context.Context, emailHash, clientIP string, failedAttempts int, lockoutDuration string) error {
event := securityevent.NewSecurityEvent(
securityevent.EventTypeAccountLocked,
securityevent.SeverityHigh,
emailHash,
clientIP,
"Account locked due to excessive failed login attempts",
)
event.WithMetadata("failed_attempts", failedAttempts)
event.WithMetadata("lockout_duration", lockoutDuration)
return s.LogEvent(ctx, event)
}
// LogAccountUnlocked logs an account unlock event
func (s *securityEventLogger) LogAccountUnlocked(ctx context.Context, emailHash, unlockedBy string) error {
event := securityevent.NewSecurityEvent(
securityevent.EventTypeAccountUnlocked,
securityevent.SeverityMedium,
emailHash,
"",
"Account manually unlocked by administrator",
)
event.WithMetadata("unlocked_by", unlockedBy)
return s.LogEvent(ctx, event)
}
// LogFailedLogin logs a failed login attempt
func (s *securityEventLogger) LogFailedLogin(ctx context.Context, emailHash, clientIP string, remainingAttempts int) error {
event := securityevent.NewSecurityEvent(
securityevent.EventTypeFailedLogin,
securityevent.SeverityMedium,
emailHash,
clientIP,
"Failed login attempt - invalid credentials",
)
event.WithMetadata("remaining_attempts", remainingAttempts)
return s.LogEvent(ctx, event)
}
// LogExcessiveFailedLogin logs excessive failed login attempts
func (s *securityEventLogger) LogExcessiveFailedLogin(ctx context.Context, emailHash, clientIP string, attemptCount int) error {
event := securityevent.NewSecurityEvent(
securityevent.EventTypeExcessiveFailedLogin,
securityevent.SeverityHigh,
emailHash,
clientIP,
"Excessive failed login attempts detected",
)
event.WithMetadata("attempt_count", attemptCount)
return s.LogEvent(ctx, event)
}
// LogSuccessfulLogin logs a successful login
func (s *securityEventLogger) LogSuccessfulLogin(ctx context.Context, emailHash, clientIP string) error {
event := securityevent.NewSecurityEvent(
securityevent.EventTypeSuccessfulLogin,
securityevent.SeverityLow,
emailHash,
clientIP,
"Successful login",
)
return s.LogEvent(ctx, event)
}
// LogIPRateLimitExceeded logs IP rate limit exceeded
func (s *securityEventLogger) LogIPRateLimitExceeded(ctx context.Context, clientIP string) error {
event := securityevent.NewSecurityEvent(
securityevent.EventTypeIPRateLimitExceeded,
securityevent.SeverityMedium,
"",
clientIP,
"IP rate limit exceeded for login attempts",
)
return s.LogEvent(ctx, event)
}

View file

@ -0,0 +1,258 @@
package service
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/cache"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
)
const (
// SessionCachePrefix is the prefix for session cache keys
SessionCachePrefix = "session:"
// UserSessionsPrefix is the prefix for user session list keys (tracks all sessions for a user)
UserSessionsPrefix = "user_sessions:"
// DefaultSessionDuration is the default session expiration time
DefaultSessionDuration = 14 * 24 * time.Hour // 14 days
)
// SessionService handles session management operations
type SessionService interface {
CreateSession(ctx context.Context, userID uint64, userUUID uuid.UUID, userEmail, userName, userRole string, tenantID uuid.UUID) (*domain.Session, error)
GetSession(ctx context.Context, sessionID string) (*domain.Session, error)
DeleteSession(ctx context.Context, sessionID string) error
// CWE-384: Session Fixation Prevention
InvalidateUserSessions(ctx context.Context, userUUID uuid.UUID) error
GetUserSessions(ctx context.Context, userUUID uuid.UUID) ([]string, error)
}
type sessionService struct {
cache cache.TwoTierCacher
logger *zap.Logger
}
// NewSessionService creates a new session service
func NewSessionService(cache cache.TwoTierCacher, logger *zap.Logger) SessionService {
return &sessionService{
cache: cache,
logger: logger.Named("session-service"),
}
}
// CreateSession creates a new session and stores it in the cache
// CWE-384: Tracks user sessions to enable invalidation on login (session fixation prevention)
func (s *sessionService) CreateSession(ctx context.Context, userID uint64, userUUID uuid.UUID, userEmail, userName, userRole string, tenantID uuid.UUID) (*domain.Session, error) {
// Create new session
session := domain.NewSession(userID, userUUID, userEmail, userName, userRole, tenantID, DefaultSessionDuration)
// Serialize session to JSON
sessionData, err := json.Marshal(session)
if err != nil {
s.logger.Error("failed to marshal session",
zap.String("session_id", session.ID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to marshal session: %w", err)
}
// Store in cache with expiry
cacheKey := SessionCachePrefix + session.ID
if err := s.cache.SetWithExpiry(ctx, cacheKey, sessionData, DefaultSessionDuration); err != nil {
s.logger.Error("failed to store session in cache",
zap.String("session_id", session.ID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to store session: %w", err)
}
// CWE-384: Track session ID for this user (for session invalidation)
if err := s.addUserSession(ctx, userUUID, session.ID); err != nil {
// Log error but don't fail session creation
s.logger.Warn("failed to track user session (non-fatal)",
zap.String("session_id", session.ID),
zap.String("user_uuid", userUUID.String()),
zap.Error(err),
)
}
// CWE-532: Use redacted email for logging
s.logger.Info("session created",
zap.String("session_id", session.ID),
zap.Uint64("user_id", userID),
logger.EmailHash(userEmail),
logger.SafeEmail("email_redacted", userEmail),
)
return session, nil
}
// GetSession retrieves a session from the cache
func (s *sessionService) GetSession(ctx context.Context, sessionID string) (*domain.Session, error) {
cacheKey := SessionCachePrefix + sessionID
// Get from cache
sessionData, err := s.cache.Get(ctx, cacheKey)
if err != nil {
s.logger.Error("failed to get session from cache",
zap.String("session_id", sessionID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get session: %w", err)
}
if sessionData == nil {
s.logger.Debug("session not found",
zap.String("session_id", sessionID),
)
return nil, fmt.Errorf("session not found")
}
// Deserialize session from JSON
var session domain.Session
if err := json.Unmarshal(sessionData, &session); err != nil {
s.logger.Error("failed to unmarshal session",
zap.String("session_id", sessionID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to unmarshal session: %w", err)
}
// Check if session is expired
if session.IsExpired() {
s.logger.Info("session expired, deleting",
zap.String("session_id", sessionID),
)
_ = s.DeleteSession(ctx, sessionID) // Best effort cleanup
return nil, fmt.Errorf("session expired")
}
s.logger.Debug("session retrieved",
zap.String("session_id", sessionID),
zap.Uint64("user_id", session.UserID),
)
return &session, nil
}
// DeleteSession removes a session from the cache
func (s *sessionService) DeleteSession(ctx context.Context, sessionID string) error {
cacheKey := SessionCachePrefix + sessionID
if err := s.cache.Delete(ctx, cacheKey); err != nil {
s.logger.Error("failed to delete session from cache",
zap.String("session_id", sessionID),
zap.Error(err),
)
return fmt.Errorf("failed to delete session: %w", err)
}
s.logger.Info("session deleted",
zap.String("session_id", sessionID),
)
return nil
}
// InvalidateUserSessions invalidates all sessions for a given user
// CWE-384: This prevents session fixation attacks by ensuring old sessions are invalidated on login
func (s *sessionService) InvalidateUserSessions(ctx context.Context, userUUID uuid.UUID) error {
s.logger.Info("invalidating all sessions for user",
zap.String("user_uuid", userUUID.String()))
// Get all session IDs for this user
sessionIDs, err := s.GetUserSessions(ctx, userUUID)
if err != nil {
s.logger.Error("failed to get user sessions for invalidation",
zap.String("user_uuid", userUUID.String()),
zap.Error(err),
)
return fmt.Errorf("failed to get user sessions: %w", err)
}
// Delete each session
for _, sessionID := range sessionIDs {
if err := s.DeleteSession(ctx, sessionID); err != nil {
// Log but continue - best effort cleanup
s.logger.Warn("failed to delete session during invalidation",
zap.String("session_id", sessionID),
zap.Error(err),
)
}
}
// Clear the user sessions list
userSessionsKey := UserSessionsPrefix + userUUID.String()
if err := s.cache.Delete(ctx, userSessionsKey); err != nil {
// Log but don't fail - this is cleanup
s.logger.Warn("failed to delete user sessions list",
zap.String("user_uuid", userUUID.String()),
zap.Error(err),
)
}
s.logger.Info("invalidated all sessions for user",
zap.String("user_uuid", userUUID.String()),
zap.Int("sessions_count", len(sessionIDs)),
)
return nil
}
// GetUserSessions retrieves all session IDs for a given user
func (s *sessionService) GetUserSessions(ctx context.Context, userUUID uuid.UUID) ([]string, error) {
userSessionsKey := UserSessionsPrefix + userUUID.String()
// Get the session IDs list from cache
data, err := s.cache.Get(ctx, userSessionsKey)
if err != nil {
return nil, fmt.Errorf("failed to get user sessions: %w", err)
}
if data == nil {
// No sessions tracked for this user
return []string{}, nil
}
// Deserialize session IDs
var sessionIDs []string
if err := json.Unmarshal(data, &sessionIDs); err != nil {
return nil, fmt.Errorf("failed to unmarshal user sessions: %w", err)
}
return sessionIDs, nil
}
// addUserSession adds a session ID to the user's session list
// CWE-384: Helper method for tracking user sessions to enable invalidation
func (s *sessionService) addUserSession(ctx context.Context, userUUID uuid.UUID, sessionID string) error {
userSessionsKey := UserSessionsPrefix + userUUID.String()
// Get existing session IDs
sessionIDs, err := s.GetUserSessions(ctx, userUUID)
if err != nil && err.Error() != "failed to get user sessions: record not found" {
return fmt.Errorf("failed to get existing sessions: %w", err)
}
// Add new session ID
sessionIDs = append(sessionIDs, sessionID)
// Serialize and store
data, err := json.Marshal(sessionIDs)
if err != nil {
return fmt.Errorf("failed to marshal session IDs: %w", err)
}
// Store with same expiry as sessions
if err := s.cache.SetWithExpiry(ctx, userSessionsKey, data, DefaultSessionDuration); err != nil {
return fmt.Errorf("failed to store user sessions: %w", err)
}
return nil
}

View file

@ -0,0 +1,35 @@
package site
import (
"context"
"go.uber.org/zap"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
)
// AuthenticateAPIKeyService handles API key authentication operations
type AuthenticateAPIKeyService interface {
AuthenticateByAPIKey(ctx context.Context, input *siteusecase.AuthenticateAPIKeyInput) (*siteusecase.AuthenticateAPIKeyOutput, error)
}
type authenticateAPIKeyService struct {
authenticateUC *siteusecase.AuthenticateAPIKeyUseCase
logger *zap.Logger
}
// NewAuthenticateAPIKeyService creates a new AuthenticateAPIKeyService
func NewAuthenticateAPIKeyService(
authenticateUC *siteusecase.AuthenticateAPIKeyUseCase,
logger *zap.Logger,
) AuthenticateAPIKeyService {
return &authenticateAPIKeyService{
authenticateUC: authenticateUC,
logger: logger.Named("authenticate-apikey-service"),
}
}
// AuthenticateByAPIKey authenticates an API key
func (s *authenticateAPIKeyService) AuthenticateByAPIKey(ctx context.Context, input *siteusecase.AuthenticateAPIKeyInput) (*siteusecase.AuthenticateAPIKeyOutput, error) {
return s.authenticateUC.Execute(ctx, input)
}

View file

@ -0,0 +1,112 @@
package site
import (
"context"
"fmt"
"github.com/gocql/gocql"
"go.uber.org/zap"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
)
// CreateSiteService handles site creation operations
type CreateSiteService interface {
CreateSite(ctx context.Context, tenantID gocql.UUID, input *siteusecase.CreateSiteInput) (*siteusecase.CreateSiteOutput, error)
}
type createSiteService struct {
// Focused usecases
validateDomainUC *siteusecase.ValidateDomainUseCase
generateAPIKeyUC *siteusecase.GenerateAPIKeyUseCase
generateVerifyTokenUC *siteusecase.GenerateVerificationTokenUseCase
createSiteEntityUC *siteusecase.CreateSiteEntityUseCase
saveSiteToRepoUC *siteusecase.SaveSiteToRepoUseCase
logger *zap.Logger
}
// NewCreateSiteService creates a new CreateSiteService
func NewCreateSiteService(
validateDomainUC *siteusecase.ValidateDomainUseCase,
generateAPIKeyUC *siteusecase.GenerateAPIKeyUseCase,
generateVerifyTokenUC *siteusecase.GenerateVerificationTokenUseCase,
createSiteEntityUC *siteusecase.CreateSiteEntityUseCase,
saveSiteToRepoUC *siteusecase.SaveSiteToRepoUseCase,
logger *zap.Logger,
) CreateSiteService {
return &createSiteService{
validateDomainUC: validateDomainUC,
generateAPIKeyUC: generateAPIKeyUC,
generateVerifyTokenUC: generateVerifyTokenUC,
createSiteEntityUC: createSiteEntityUC,
saveSiteToRepoUC: saveSiteToRepoUC,
logger: logger.Named("create-site-service"),
}
}
// CreateSite orchestrates the site creation workflow
func (s *createSiteService) CreateSite(ctx context.Context, tenantID gocql.UUID, input *siteusecase.CreateSiteInput) (*siteusecase.CreateSiteOutput, error) {
s.logger.Info("creating site",
zap.String("tenant_id", tenantID.String()),
zap.String("domain", input.Domain))
// Step 1: Validate domain availability
if err := s.validateDomainUC.Execute(ctx, input.Domain); err != nil {
s.logger.Error("domain validation failed",
zap.String("domain", input.Domain),
zap.Error(err))
return nil, err
}
// Step 2: Generate API key
apiKeyResult, err := s.generateAPIKeyUC.Execute(input.TestMode)
if err != nil {
s.logger.Error("API key generation failed", zap.Error(err))
return nil, fmt.Errorf("failed to generate API key: %w", err)
}
// Step 3: Generate verification token
verificationToken, err := s.generateVerifyTokenUC.Execute()
if err != nil {
s.logger.Error("verification token generation failed", zap.Error(err))
return nil, fmt.Errorf("failed to generate verification token: %w", err)
}
// Step 4: Create site entity (no plan tier - usage-based billing)
site, err := s.createSiteEntityUC.Execute(&siteusecase.CreateSiteEntityInput{
TenantID: tenantID,
Domain: input.Domain,
SiteURL: input.SiteURL,
APIKeyHash: apiKeyResult.HashedKey,
APIKeyPrefix: apiKeyResult.Prefix,
APIKeyLastFour: apiKeyResult.LastFour,
VerificationToken: verificationToken,
IPAddress: input.IPAddress,
})
if err != nil {
s.logger.Error("failed to create site entity", zap.Error(err))
return nil, err
}
// Step 5: Save site to repository
if err := s.saveSiteToRepoUC.Execute(ctx, site); err != nil {
s.logger.Error("failed to save site", zap.Error(err))
return nil, err
}
s.logger.Info("site created successfully",
zap.String("site_id", site.ID.String()),
zap.String("domain", site.Domain))
// Step 6: Build output
return &siteusecase.CreateSiteOutput{
ID: site.ID.String(),
Domain: site.Domain,
SiteURL: site.SiteURL,
APIKey: apiKeyResult.PlaintextKey, // PLAINTEXT - only shown once!
VerificationToken: verificationToken,
Status: site.Status,
SearchIndexName: site.SearchIndexName,
}, nil
}

View file

@ -0,0 +1,77 @@
package site
import (
"context"
"github.com/gocql/gocql"
"go.uber.org/zap"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
)
// DeleteSiteService handles site deletion operations
type DeleteSiteService interface {
DeleteSite(ctx context.Context, tenantID gocql.UUID, input *siteusecase.DeleteSiteInput) (*siteusecase.DeleteSiteOutput, error)
}
type deleteSiteService struct {
// Focused usecases
validateSiteForDeletionUC *siteusecase.ValidateSiteForDeletionUseCase
deleteSiteFromRepoUC *siteusecase.DeleteSiteFromRepoUseCase
logger *zap.Logger
}
// NewDeleteSiteService creates a new DeleteSiteService
func NewDeleteSiteService(
validateSiteForDeletionUC *siteusecase.ValidateSiteForDeletionUseCase,
deleteSiteFromRepoUC *siteusecase.DeleteSiteFromRepoUseCase,
logger *zap.Logger,
) DeleteSiteService {
return &deleteSiteService{
validateSiteForDeletionUC: validateSiteForDeletionUC,
deleteSiteFromRepoUC: deleteSiteFromRepoUC,
logger: logger.Named("delete-site-service"),
}
}
// DeleteSite orchestrates the site deletion workflow
func (s *deleteSiteService) DeleteSite(ctx context.Context, tenantID gocql.UUID, input *siteusecase.DeleteSiteInput) (*siteusecase.DeleteSiteOutput, error) {
s.logger.Info("deleting site",
zap.String("tenant_id", tenantID.String()),
zap.String("site_id", input.SiteID))
// Step 1: Parse site ID
siteID, err := gocql.ParseUUID(input.SiteID)
if err != nil {
s.logger.Error("invalid site ID", zap.Error(err))
return nil, err
}
// Step 2: Validate site exists before deletion
site, err := s.validateSiteForDeletionUC.Execute(ctx, tenantID, siteID)
if err != nil {
s.logger.Error("site validation failed",
zap.String("site_id", input.SiteID),
zap.Error(err))
return nil, err
}
// Step 3: Delete site from repository (all tables)
if err := s.deleteSiteFromRepoUC.Execute(ctx, tenantID, siteID); err != nil {
s.logger.Error("failed to delete site from repository",
zap.String("site_id", input.SiteID),
zap.Error(err))
return nil, err
}
s.logger.Info("site deleted successfully",
zap.String("site_id", input.SiteID),
zap.String("domain", site.Domain))
// Step 4: Build output
return &siteusecase.DeleteSiteOutput{
Success: true,
Message: "Site deleted successfully",
}, nil
}

View file

@ -0,0 +1,36 @@
package site
import (
"context"
"github.com/gocql/gocql"
"go.uber.org/zap"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
)
// GetSiteService handles getting a single site
type GetSiteService interface {
GetSite(ctx context.Context, tenantID gocql.UUID, input *siteusecase.GetSiteInput) (*siteusecase.GetSiteOutput, error)
}
type getSiteService struct {
getUC *siteusecase.GetSiteUseCase
logger *zap.Logger
}
// NewGetSiteService creates a new GetSiteService
func NewGetSiteService(
getUC *siteusecase.GetSiteUseCase,
logger *zap.Logger,
) GetSiteService {
return &getSiteService{
getUC: getUC,
logger: logger.Named("get-site-service"),
}
}
// GetSite retrieves a site by ID
func (s *getSiteService) GetSite(ctx context.Context, tenantID gocql.UUID, input *siteusecase.GetSiteInput) (*siteusecase.GetSiteOutput, error) {
return s.getUC.Execute(ctx, tenantID, input)
}

View file

@ -0,0 +1,36 @@
package site
import (
"context"
"github.com/gocql/gocql"
"go.uber.org/zap"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
)
// ListSitesService handles listing sites
type ListSitesService interface {
ListSites(ctx context.Context, tenantID gocql.UUID, input *siteusecase.ListSitesInput) (*siteusecase.ListSitesOutput, error)
}
type listSitesService struct {
listUC *siteusecase.ListSitesUseCase
logger *zap.Logger
}
// NewListSitesService creates a new ListSitesService
func NewListSitesService(
listUC *siteusecase.ListSitesUseCase,
logger *zap.Logger,
) ListSitesService {
return &listSitesService{
listUC: listUC,
logger: logger.Named("list-sites-service"),
}
}
// ListSites retrieves all sites for a tenant
func (s *listSitesService) ListSites(ctx context.Context, tenantID gocql.UUID, input *siteusecase.ListSitesInput) (*siteusecase.ListSitesOutput, error) {
return s.listUC.Execute(ctx, tenantID, input)
}

View file

@ -0,0 +1,80 @@
package site
import (
"go.uber.org/zap"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
)
// ProvideCreateSiteService creates a new CreateSiteService for dependency injection
func ProvideCreateSiteService(
validateDomainUC *siteusecase.ValidateDomainUseCase,
generateAPIKeyUC *siteusecase.GenerateAPIKeyUseCase,
generateVerifyTokenUC *siteusecase.GenerateVerificationTokenUseCase,
createSiteEntityUC *siteusecase.CreateSiteEntityUseCase,
saveSiteToRepoUC *siteusecase.SaveSiteToRepoUseCase,
logger *zap.Logger,
) CreateSiteService {
return NewCreateSiteService(
validateDomainUC,
generateAPIKeyUC,
generateVerifyTokenUC,
createSiteEntityUC,
saveSiteToRepoUC,
logger,
)
}
// ProvideGetSiteService creates a new GetSiteService for dependency injection
func ProvideGetSiteService(
getUC *siteusecase.GetSiteUseCase,
logger *zap.Logger,
) GetSiteService {
return NewGetSiteService(getUC, logger)
}
// ProvideListSitesService creates a new ListSitesService for dependency injection
func ProvideListSitesService(
listUC *siteusecase.ListSitesUseCase,
logger *zap.Logger,
) ListSitesService {
return NewListSitesService(listUC, logger)
}
// ProvideDeleteSiteService creates a new DeleteSiteService for dependency injection
func ProvideDeleteSiteService(
validateSiteForDeletionUC *siteusecase.ValidateSiteForDeletionUseCase,
deleteSiteFromRepoUC *siteusecase.DeleteSiteFromRepoUseCase,
logger *zap.Logger,
) DeleteSiteService {
return NewDeleteSiteService(
validateSiteForDeletionUC,
deleteSiteFromRepoUC,
logger,
)
}
// ProvideRotateAPIKeyService creates a new RotateAPIKeyService for dependency injection
func ProvideRotateAPIKeyService(
getSiteUC *siteusecase.GetSiteUseCase,
generateAPIKeyUC *siteusecase.GenerateAPIKeyUseCase,
updateSiteAPIKeyUC *siteusecase.UpdateSiteAPIKeyUseCase,
updateSiteAPIKeyToRepoUC *siteusecase.UpdateSiteAPIKeyToRepoUseCase,
logger *zap.Logger,
) RotateAPIKeyService {
return NewRotateAPIKeyService(
getSiteUC,
generateAPIKeyUC,
updateSiteAPIKeyUC,
updateSiteAPIKeyToRepoUC,
logger,
)
}
// ProvideAuthenticateAPIKeyService creates a new AuthenticateAPIKeyService for dependency injection
func ProvideAuthenticateAPIKeyService(
authenticateUC *siteusecase.AuthenticateAPIKeyUseCase,
logger *zap.Logger,
) AuthenticateAPIKeyService {
return NewAuthenticateAPIKeyService(authenticateUC, logger)
}

View file

@ -0,0 +1,114 @@
package site
import (
"context"
"fmt"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
)
// RotateAPIKeyService handles API key rotation operations
type RotateAPIKeyService interface {
RotateAPIKey(ctx context.Context, tenantID gocql.UUID, input *siteusecase.RotateAPIKeyInput) (*siteusecase.RotateAPIKeyOutput, error)
}
type rotateAPIKeyService struct {
// Focused usecases
getSiteUC *siteusecase.GetSiteUseCase
generateAPIKeyUC *siteusecase.GenerateAPIKeyUseCase
updateSiteAPIKeyUC *siteusecase.UpdateSiteAPIKeyUseCase
updateSiteAPIKeyToRepoUC *siteusecase.UpdateSiteAPIKeyToRepoUseCase
logger *zap.Logger
}
// NewRotateAPIKeyService creates a new RotateAPIKeyService
func NewRotateAPIKeyService(
getSiteUC *siteusecase.GetSiteUseCase,
generateAPIKeyUC *siteusecase.GenerateAPIKeyUseCase,
updateSiteAPIKeyUC *siteusecase.UpdateSiteAPIKeyUseCase,
updateSiteAPIKeyToRepoUC *siteusecase.UpdateSiteAPIKeyToRepoUseCase,
logger *zap.Logger,
) RotateAPIKeyService {
return &rotateAPIKeyService{
getSiteUC: getSiteUC,
generateAPIKeyUC: generateAPIKeyUC,
updateSiteAPIKeyUC: updateSiteAPIKeyUC,
updateSiteAPIKeyToRepoUC: updateSiteAPIKeyToRepoUC,
logger: logger.Named("rotate-apikey-service"),
}
}
// RotateAPIKey orchestrates the API key rotation workflow
func (s *rotateAPIKeyService) RotateAPIKey(ctx context.Context, tenantID gocql.UUID, input *siteusecase.RotateAPIKeyInput) (*siteusecase.RotateAPIKeyOutput, error) {
s.logger.Info("rotating API key",
zap.String("tenant_id", tenantID.String()),
zap.String("site_id", input.SiteID))
// Step 1: Get current site
siteOutput, err := s.getSiteUC.Execute(ctx, tenantID, &siteusecase.GetSiteInput{
ID: input.SiteID,
})
if err != nil {
s.logger.Error("failed to get site",
zap.String("site_id", input.SiteID),
zap.Error(err))
return nil, err
}
site := siteOutput.Site
// Step 2: Store old key info for response and rotation
oldKeyLastFour := site.APIKeyLastFour
oldAPIKeyHash := site.APIKeyHash
// Step 3: Determine test mode from existing API key prefix
// If current key starts with "test_", generate a test key; otherwise generate live key
testMode := len(site.APIKeyPrefix) >= 5 && site.APIKeyPrefix[:5] == "test_"
s.logger.Info("generating new API key",
zap.Bool("test_mode", testMode),
zap.String("current_key_prefix", site.APIKeyPrefix),
zap.String("site_id", input.SiteID))
apiKeyResult, err := s.generateAPIKeyUC.Execute(testMode)
if err != nil {
s.logger.Error("failed to generate new API key", zap.Error(err))
return nil, fmt.Errorf("failed to generate API key: %w", err)
}
// Step 4: Update site entity with new key details
s.updateSiteAPIKeyUC.Execute(&siteusecase.UpdateSiteAPIKeyInput{
Site: site,
NewAPIKeyHash: apiKeyResult.HashedKey,
NewKeyPrefix: apiKeyResult.Prefix,
NewKeyLastFour: apiKeyResult.LastFour,
})
// Step 5: Update site API key in repository (all tables)
// Use UpdateSiteAPIKeyToRepoUC to properly handle sites_by_apikey table (delete old + insert new)
if err := s.updateSiteAPIKeyToRepoUC.Execute(ctx, &siteusecase.UpdateSiteAPIKeyToRepoInput{
Site: site,
OldAPIKeyHash: oldAPIKeyHash,
}); err != nil {
s.logger.Error("failed to update site with new API key", zap.Error(err))
return nil, err
}
// Step 6: Build output
rotatedAt := time.Now()
s.logger.Info("API key rotated successfully",
zap.String("site_id", input.SiteID),
zap.String("old_key_last_four", oldKeyLastFour),
zap.String("new_key_prefix", apiKeyResult.Prefix),
zap.String("new_key_last_four", apiKeyResult.LastFour))
return &siteusecase.RotateAPIKeyOutput{
NewAPIKey: apiKeyResult.PlaintextKey, // PLAINTEXT - only shown once!
OldKeyLastFour: oldKeyLastFour,
RotatedAt: rotatedAt,
}, nil
}

View file

@ -0,0 +1,53 @@
package site
import (
"context"
"github.com/gocql/gocql"
"go.uber.org/zap"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
)
// VerifySiteService handles site verification operations
type VerifySiteService interface {
VerifySite(ctx context.Context, tenantID gocql.UUID, siteID gocql.UUID, input *siteusecase.VerifySiteInput) (*siteusecase.VerifySiteOutput, error)
}
type verifySiteService struct {
verifySiteUC *siteusecase.VerifySiteUseCase
logger *zap.Logger
}
// NewVerifySiteService creates a new VerifySiteService
func NewVerifySiteService(
verifySiteUC *siteusecase.VerifySiteUseCase,
logger *zap.Logger,
) VerifySiteService {
return &verifySiteService{
verifySiteUC: verifySiteUC,
logger: logger.Named("verify-site-service"),
}
}
// ProvideVerifySiteService provides VerifySiteService for dependency injection
func ProvideVerifySiteService(
verifySiteUC *siteusecase.VerifySiteUseCase,
logger *zap.Logger,
) VerifySiteService {
return NewVerifySiteService(verifySiteUC, logger)
}
// VerifySite verifies a site using the verification token
func (s *verifySiteService) VerifySite(
ctx context.Context,
tenantID gocql.UUID,
siteID gocql.UUID,
input *siteusecase.VerifySiteInput,
) (*siteusecase.VerifySiteOutput, error) {
s.logger.Info("verifying site",
zap.String("tenant_id", tenantID.String()),
zap.String("site_id", siteID.String()))
return s.verifySiteUC.Execute(ctx, tenantID, siteID, input)
}

View file

@ -0,0 +1,92 @@
package tenant
import (
"context"
"go.uber.org/zap"
tenantusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/tenant"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
)
// CreateTenantService handles tenant creation operations
type CreateTenantService interface {
CreateTenant(ctx context.Context, input *tenantusecase.CreateTenantInput) (*tenantusecase.CreateTenantOutput, error)
}
type createTenantService struct {
// Focused usecases
validateSlugUC *tenantusecase.ValidateTenantSlugUniqueUseCase
createEntityUC *tenantusecase.CreateTenantEntityUseCase
saveTenantToRepoUC *tenantusecase.SaveTenantToRepoUseCase
logger *zap.Logger
}
// NewCreateTenantService creates a new CreateTenantService
func NewCreateTenantService(
validateSlugUC *tenantusecase.ValidateTenantSlugUniqueUseCase,
createEntityUC *tenantusecase.CreateTenantEntityUseCase,
saveTenantToRepoUC *tenantusecase.SaveTenantToRepoUseCase,
logger *zap.Logger,
) CreateTenantService {
return &createTenantService{
validateSlugUC: validateSlugUC,
createEntityUC: createEntityUC,
saveTenantToRepoUC: saveTenantToRepoUC,
logger: logger.Named("create-tenant-service"),
}
}
// CreateTenant orchestrates the tenant creation workflow
func (s *createTenantService) CreateTenant(ctx context.Context, input *tenantusecase.CreateTenantInput) (*tenantusecase.CreateTenantOutput, error) {
// CWE-532: Use redacted tenant slug for logging
s.logger.Info("creating tenant",
logger.TenantSlugHash(input.Slug),
logger.SafeTenantSlug("tenant_slug_redacted", input.Slug),
zap.String("name", input.Name))
// Step 1: Validate slug uniqueness (fail fast)
if err := s.validateSlugUC.Execute(ctx, input.Slug); err != nil {
// CWE-532: Use redacted tenant slug for logging
s.logger.Error("slug validation failed",
logger.TenantSlugHash(input.Slug),
logger.SafeTenantSlug("tenant_slug_redacted", input.Slug),
zap.Error(err))
return nil, err
}
// Step 2: Create and validate tenant entity
tenant, err := s.createEntityUC.Execute(input)
if err != nil {
// CWE-532: Use redacted tenant slug for logging
s.logger.Error("entity creation failed",
logger.TenantSlugHash(input.Slug),
logger.SafeTenantSlug("tenant_slug_redacted", input.Slug),
zap.Error(err))
return nil, err
}
// Step 3: Save tenant to repository
if err := s.saveTenantToRepoUC.Execute(ctx, tenant); err != nil {
s.logger.Error("failed to save tenant",
zap.String("tenant_id", tenant.ID),
zap.Error(err))
return nil, err
}
// CWE-532: Use redacted tenant slug for logging
s.logger.Info("tenant created successfully",
zap.String("tenant_id", tenant.ID),
logger.TenantSlugHash(tenant.Slug),
logger.SafeTenantSlug("tenant_slug_redacted", tenant.Slug))
// Step 4: Build output
return &tenantusecase.CreateTenantOutput{
ID: tenant.ID,
Name: tenant.Name,
Slug: tenant.Slug,
Status: string(tenant.Status),
CreatedAt: tenant.CreatedAt,
}, nil
}

View file

@ -0,0 +1,41 @@
package tenant
import (
"context"
"go.uber.org/zap"
tenantusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/tenant"
)
// GetTenantService handles getting tenant information
type GetTenantService interface {
GetTenant(ctx context.Context, input *tenantusecase.GetTenantInput) (*tenantusecase.GetTenantOutput, error)
GetTenantBySlug(ctx context.Context, input *tenantusecase.GetTenantBySlugInput) (*tenantusecase.GetTenantOutput, error)
}
type getTenantService struct {
getUC *tenantusecase.GetTenantUseCase
logger *zap.Logger
}
// NewGetTenantService creates a new GetTenantService
func NewGetTenantService(
getUC *tenantusecase.GetTenantUseCase,
logger *zap.Logger,
) GetTenantService {
return &getTenantService{
getUC: getUC,
logger: logger.Named("get-tenant-service"),
}
}
// GetTenant retrieves a tenant by ID
func (s *getTenantService) GetTenant(ctx context.Context, input *tenantusecase.GetTenantInput) (*tenantusecase.GetTenantOutput, error) {
return s.getUC.Execute(ctx, input)
}
// GetTenantBySlug retrieves a tenant by slug
func (s *getTenantService) GetTenantBySlug(ctx context.Context, input *tenantusecase.GetTenantBySlugInput) (*tenantusecase.GetTenantOutput, error) {
return s.getUC.ExecuteBySlug(ctx, input)
}

View file

@ -0,0 +1,30 @@
package tenant
import (
"go.uber.org/zap"
tenantusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/tenant"
)
// ProvideCreateTenantService creates a new CreateTenantService for dependency injection
func ProvideCreateTenantService(
validateSlugUC *tenantusecase.ValidateTenantSlugUniqueUseCase,
createEntityUC *tenantusecase.CreateTenantEntityUseCase,
saveTenantToRepoUC *tenantusecase.SaveTenantToRepoUseCase,
logger *zap.Logger,
) CreateTenantService {
return NewCreateTenantService(
validateSlugUC,
createEntityUC,
saveTenantToRepoUC,
logger,
)
}
// ProvideGetTenantService creates a new GetTenantService for dependency injection
func ProvideGetTenantService(
getUC *tenantusecase.GetTenantUseCase,
logger *zap.Logger,
) GetTenantService {
return NewGetTenantService(getUC, logger)
}

View file

@ -0,0 +1,91 @@
package user
import (
"context"
"go.uber.org/zap"
userusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
)
// CreateUserService handles user creation operations
type CreateUserService interface {
CreateUser(ctx context.Context, tenantID string, input *userusecase.CreateUserInput) (*userusecase.CreateUserOutput, error)
}
type createUserService struct {
// Focused usecases
validateEmailUC *userusecase.ValidateUserEmailUniqueUseCase
createEntityUC *userusecase.CreateUserEntityUseCase
saveUserToRepoUC *userusecase.SaveUserToRepoUseCase
logger *zap.Logger
}
// NewCreateUserService creates a new CreateUserService
func NewCreateUserService(
validateEmailUC *userusecase.ValidateUserEmailUniqueUseCase,
createEntityUC *userusecase.CreateUserEntityUseCase,
saveUserToRepoUC *userusecase.SaveUserToRepoUseCase,
logger *zap.Logger,
) CreateUserService {
return &createUserService{
validateEmailUC: validateEmailUC,
createEntityUC: createEntityUC,
saveUserToRepoUC: saveUserToRepoUC,
logger: logger.Named("create-user-service"),
}
}
// CreateUser orchestrates the user creation workflow
func (s *createUserService) CreateUser(ctx context.Context, tenantID string, input *userusecase.CreateUserInput) (*userusecase.CreateUserOutput, error) {
// CWE-532: Use redacted email for logging
s.logger.Info("creating user",
zap.String("tenant_id", tenantID),
logger.EmailHash(input.Email),
logger.SafeEmail("email_redacted", input.Email))
// Step 1: Validate email uniqueness (fail fast)
if err := s.validateEmailUC.Execute(ctx, tenantID, input.Email); err != nil {
// CWE-532: Use redacted email for logging
s.logger.Error("email validation failed",
logger.EmailHash(input.Email),
logger.SafeEmail("email_redacted", input.Email),
zap.Error(err))
return nil, err
}
// Step 2: Create and validate user entity
user, err := s.createEntityUC.Execute(tenantID, input)
if err != nil {
// CWE-532: Use redacted email for logging
s.logger.Error("entity creation failed",
logger.EmailHash(input.Email),
logger.SafeEmail("email_redacted", input.Email),
zap.Error(err))
return nil, err
}
// Step 3: Save user to repository
if err := s.saveUserToRepoUC.Execute(ctx, tenantID, user); err != nil {
s.logger.Error("failed to save user",
zap.String("user_id", user.ID),
zap.Error(err))
return nil, err
}
// CWE-532: Use redacted email for logging
s.logger.Info("user created successfully",
zap.String("user_id", user.ID),
logger.EmailHash(user.Email),
logger.SafeEmail("email_redacted", user.Email))
// Step 4: Build output
return &userusecase.CreateUserOutput{
ID: user.ID,
Email: user.Email,
Name: user.Name,
CreatedAt: user.CreatedAt,
}, nil
}

View file

@ -0,0 +1,35 @@
package user
import (
"context"
"go.uber.org/zap"
userusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/user"
)
// GetUserService handles getting user information
type GetUserService interface {
GetUser(ctx context.Context, tenantID string, input *userusecase.GetUserInput) (*userusecase.GetUserOutput, error)
}
type getUserService struct {
getUC *userusecase.GetUserUseCase
logger *zap.Logger
}
// NewGetUserService creates a new GetUserService
func NewGetUserService(
getUC *userusecase.GetUserUseCase,
logger *zap.Logger,
) GetUserService {
return &getUserService{
getUC: getUC,
logger: logger.Named("get-user-service"),
}
}
// GetUser retrieves a user by ID
func (s *getUserService) GetUser(ctx context.Context, tenantID string, input *userusecase.GetUserInput) (*userusecase.GetUserOutput, error) {
return s.getUC.Execute(ctx, tenantID, input)
}

View file

@ -0,0 +1,30 @@
package user
import (
"go.uber.org/zap"
userusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/user"
)
// ProvideCreateUserService creates a new CreateUserService for dependency injection
func ProvideCreateUserService(
validateEmailUC *userusecase.ValidateUserEmailUniqueUseCase,
createEntityUC *userusecase.CreateUserEntityUseCase,
saveUserToRepoUC *userusecase.SaveUserToRepoUseCase,
logger *zap.Logger,
) CreateUserService {
return NewCreateUserService(
validateEmailUC,
createEntityUC,
saveUserToRepoUC,
logger,
)
}
// ProvideGetUserService creates a new GetUserService for dependency injection
func ProvideGetUserService(
getUC *userusecase.GetUserUseCase,
logger *zap.Logger,
) GetUserService {
return NewGetUserService(getUC, logger)
}