Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
165
cloud/maplepress-backend/internal/service/gateway/login.go
Normal file
165
cloud/maplepress-backend/internal/service/gateway/login.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
123
cloud/maplepress-backend/internal/service/gateway/refresh.go
Normal file
123
cloud/maplepress-backend/internal/service/gateway/refresh.go
Normal 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
|
||||
}
|
||||
389
cloud/maplepress-backend/internal/service/gateway/register.go
Normal file
389
cloud/maplepress-backend/internal/service/gateway/register.go
Normal 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 ®isterService{
|
||||
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
|
||||
}
|
||||
408
cloud/maplepress-backend/internal/service/ipcleanup/cleanup.go
Normal file
408
cloud/maplepress-backend/internal/service/ipcleanup/cleanup.go
Normal 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)
|
||||
}
|
||||
148
cloud/maplepress-backend/internal/service/page/delete.go
Normal file
148
cloud/maplepress-backend/internal/service/page/delete.go
Normal 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
|
||||
}
|
||||
80
cloud/maplepress-backend/internal/service/page/search.go
Normal file
80
cloud/maplepress-backend/internal/service/page/search.go
Normal 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
|
||||
}
|
||||
133
cloud/maplepress-backend/internal/service/page/status.go
Normal file
133
cloud/maplepress-backend/internal/service/page/status.go
Normal 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
|
||||
}
|
||||
143
cloud/maplepress-backend/internal/service/page/sync.go
Normal file
143
cloud/maplepress-backend/internal/service/page/sync.go
Normal 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
|
||||
}
|
||||
12
cloud/maplepress-backend/internal/service/provider.go
Normal file
12
cloud/maplepress-backend/internal/service/provider.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
258
cloud/maplepress-backend/internal/service/session.go
Normal file
258
cloud/maplepress-backend/internal/service/session.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
112
cloud/maplepress-backend/internal/service/site/create.go
Normal file
112
cloud/maplepress-backend/internal/service/site/create.go
Normal 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
|
||||
}
|
||||
77
cloud/maplepress-backend/internal/service/site/delete.go
Normal file
77
cloud/maplepress-backend/internal/service/site/delete.go
Normal 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
|
||||
}
|
||||
36
cloud/maplepress-backend/internal/service/site/get.go
Normal file
36
cloud/maplepress-backend/internal/service/site/get.go
Normal 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)
|
||||
}
|
||||
36
cloud/maplepress-backend/internal/service/site/list.go
Normal file
36
cloud/maplepress-backend/internal/service/site/list.go
Normal 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)
|
||||
}
|
||||
80
cloud/maplepress-backend/internal/service/site/provider.go
Normal file
80
cloud/maplepress-backend/internal/service/site/provider.go
Normal 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)
|
||||
}
|
||||
114
cloud/maplepress-backend/internal/service/site/rotate_apikey.go
Normal file
114
cloud/maplepress-backend/internal/service/site/rotate_apikey.go
Normal 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
|
||||
}
|
||||
53
cloud/maplepress-backend/internal/service/site/verify.go
Normal file
53
cloud/maplepress-backend/internal/service/site/verify.go
Normal 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)
|
||||
}
|
||||
92
cloud/maplepress-backend/internal/service/tenant/create.go
Normal file
92
cloud/maplepress-backend/internal/service/tenant/create.go
Normal 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
|
||||
}
|
||||
41
cloud/maplepress-backend/internal/service/tenant/get.go
Normal file
41
cloud/maplepress-backend/internal/service/tenant/get.go
Normal 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)
|
||||
}
|
||||
30
cloud/maplepress-backend/internal/service/tenant/provider.go
Normal file
30
cloud/maplepress-backend/internal/service/tenant/provider.go
Normal 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)
|
||||
}
|
||||
91
cloud/maplepress-backend/internal/service/user/create.go
Normal file
91
cloud/maplepress-backend/internal/service/user/create.go
Normal 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
|
||||
}
|
||||
35
cloud/maplepress-backend/internal/service/user/get.go
Normal file
35
cloud/maplepress-backend/internal/service/user/get.go
Normal 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)
|
||||
}
|
||||
30
cloud/maplepress-backend/internal/service/user/provider.go
Normal file
30
cloud/maplepress-backend/internal/service/user/provider.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue