Files
Jay Brown 62b5de5722 Merged in bug/eula-email (pull request #212)
fix eula issue when email not present in jwt

* bug fix
2026-02-26 21:46:00 +00:00

550 lines
17 KiB
Go

// eulaAdminHandlers.go implements the admin EULA API handlers.
// These endpoints handle EULA version management, agreement listing, and compliance reporting.
package queryapi
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"queryorchestration/internal/database/repository"
"queryorchestration/internal/eula"
"queryorchestration/internal/serviceconfig/aws"
"github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/oapi-codegen/nullable"
openapi_types "github.com/oapi-codegen/runtime/types"
)
// ListEulaVersions returns a paginated list of all EULA versions.
// GET /admin/eula
//
// Requires AdminService permission.
//
// Parameters:
// - page: Page number (1-based, default: 1)
// - page_size: Items per page (1-100, default: 20)
//
// Returns:
// - 200 OK with EulaVersionList on success
// - 401 Unauthorized if not authenticated
// - 403 Forbidden if not authorized
// - 500 Internal Server Error on database errors
func (s *Controllers) ListEulaVersions(ctx echo.Context, params ListEulaVersionsParams) error {
// Apply defaults
page := int32(1)
pageSize := int32(20)
if params.Page != nil {
page = *params.Page
}
if params.PageSize != nil {
pageSize = *params.PageSize
}
result, err := s.svc.Eula.ListVersions(ctx.Request().Context(), &eula.ListVersionsInput{
Page: page,
PageSize: pageSize,
})
if err != nil {
s.cfg.GetAuthLogger().Error("ListEulaVersions: failed to list EULA versions",
"error", err,
"page", page,
"page_size", pageSize)
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: fmt.Sprintf("ListEulaVersions: failed to list EULA versions: %v", err),
})
}
// Convert to API response
versions := make([]EulaVersion, len(result.Versions))
for i, v := range result.Versions {
versions[i] = convertVersionToAPI(v)
}
response := EulaVersionList{
Versions: versions,
Total: int32(result.Total), // #nosec G115 -- Total is bounded by practical EULA version counts (< 1000)
Page: page,
PageSize: pageSize,
HasMore: &result.HasMore,
}
return ctx.JSON(http.StatusOK, response)
}
// CreateEulaVersion creates a new EULA version.
// POST /admin/eula
//
// Requires AdminService permission.
//
// Request body: EulaVersionCreate
//
// Returns:
// - 201 Created with EulaVersion on success
// - 400 Bad Request on validation errors
// - 401 Unauthorized if not authenticated
// - 403 Forbidden if not authorized
// - 409 Conflict if version string already exists
// - 500 Internal Server Error on database errors
func (s *Controllers) CreateEulaVersion(ctx echo.Context) error {
var req EulaVersionCreate
if err := ctx.Bind(&req); err != nil {
return ctx.JSON(http.StatusBadRequest, ErrorMessage{
Message: "Invalid request body",
})
}
// Get admin user for audit
adminUser, err := getAdminUserForAudit(ctx)
if err != nil {
return ctx.JSON(http.StatusUnauthorized, ErrorMessage{
Message: "Could not identify admin user",
})
}
// Extract effectiveDate if specified (nullable field)
var effectiveDate *time.Time
if req.EffectiveDate.IsSpecified() && !req.EffectiveDate.IsNull() {
val := req.EffectiveDate.MustGet()
effectiveDate = &val
}
version, err := s.svc.Eula.CreateVersion(ctx.Request().Context(), &eula.CreateVersionInput{
Version: req.Version,
Title: req.Title,
Content: req.Content,
EffectiveDate: effectiveDate,
CreatedBy: adminUser.Email,
})
if err != nil {
// Check for duplicate version
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
return ctx.JSON(http.StatusConflict, ErrorMessage{
Message: "EULA version already exists",
})
}
s.cfg.GetAuthLogger().Error("CreateEulaVersion: failed to create EULA version",
"error", err,
"version", req.Version,
"created_by", adminUser.Email)
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: fmt.Sprintf("Failed to create EULA version: %v", err),
})
}
return ctx.JSON(http.StatusCreated, convertVersionToAPI(version))
}
// GetEulaVersion retrieves a specific EULA version by ID.
// GET /admin/eula/{version_id}
//
// Requires AdminService permission.
//
// Returns:
// - 200 OK with EulaVersion on success
// - 401 Unauthorized if not authenticated
// - 403 Forbidden if not authorized
// - 404 Not Found if version doesn't exist
// - 500 Internal Server Error on database errors
func (s *Controllers) GetEulaVersion(ctx echo.Context, versionID EulaVersionID) error {
id, err := convertVersionIDParam(versionID)
if err != nil {
return ctx.JSON(http.StatusBadRequest, ErrorMessage{
Message: "Invalid version ID format",
})
}
version, err := s.svc.Eula.GetVersionByID(ctx.Request().Context(), id)
if err != nil {
if errors.Is(err, eula.ErrVersionNotFound) {
return ctx.JSON(http.StatusNotFound, ErrorMessage{
Message: "EULA version not found",
})
}
s.cfg.GetAuthLogger().Error("GetEulaVersion: failed to get EULA version",
"error", err,
"version_id", id)
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: fmt.Sprintf("Failed to get EULA version: %v", err),
})
}
return ctx.JSON(http.StatusOK, convertVersionToAPI(version))
}
// UpdateEulaVersion updates the metadata of an EULA version.
// Only title and effective date can be updated; content is immutable.
// PATCH /admin/eula/{version_id}
//
// Requires AdminService permission.
//
// Request body: EulaVersionUpdate
//
// Returns:
// - 200 OK with updated EulaVersion on success
// - 400 Bad Request on validation errors
// - 401 Unauthorized if not authenticated
// - 403 Forbidden if not authorized
// - 404 Not Found if version doesn't exist
// - 500 Internal Server Error on database errors
func (s *Controllers) UpdateEulaVersion(ctx echo.Context, versionID EulaVersionID) error {
id, err := convertVersionIDParam(versionID)
if err != nil {
return ctx.JSON(http.StatusBadRequest, ErrorMessage{
Message: "Invalid version ID format",
})
}
var req EulaVersionUpdate
if err := ctx.Bind(&req); err != nil {
return ctx.JSON(http.StatusBadRequest, ErrorMessage{
Message: "Invalid request body",
})
}
input := &eula.UpdateVersionInput{}
if req.Title != nil {
input.Title = req.Title
}
if req.EffectiveDate != nil {
input.EffectiveDate = req.EffectiveDate
}
version, err := s.svc.Eula.UpdateVersion(ctx.Request().Context(), id, input)
if err != nil {
if errors.Is(err, eula.ErrVersionNotFound) {
return ctx.JSON(http.StatusNotFound, ErrorMessage{
Message: "EULA version not found",
})
}
s.cfg.GetAuthLogger().Error("UpdateEulaVersion: failed to update EULA version",
"error", err,
"version_id", id)
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: fmt.Sprintf("Failed to update EULA version: %v", err),
})
}
return ctx.JSON(http.StatusOK, convertVersionToAPI(version))
}
// ActivateEulaVersion sets the specified version as the current active EULA.
// POST /admin/eula/{version_id}/activate
//
// Requires AdminService permission.
// Uses a transaction to atomically clear the previous current flag and set the new one.
//
// Returns:
// - 200 OK with activated EulaVersion on success
// - 401 Unauthorized if not authenticated
// - 403 Forbidden if not authorized
// - 404 Not Found if version doesn't exist
// - 409 Conflict if another admin activated a different version concurrently
// - 500 Internal Server Error on database errors
func (s *Controllers) ActivateEulaVersion(ctx echo.Context, versionID EulaVersionID) error {
id, err := convertVersionIDParam(versionID)
if err != nil {
return ctx.JSON(http.StatusBadRequest, ErrorMessage{
Message: "Invalid version ID format",
})
}
// Get admin user for audit
adminUser, err := getAdminUserForAudit(ctx)
if err != nil {
return ctx.JSON(http.StatusUnauthorized, ErrorMessage{
Message: "Could not identify admin user",
})
}
version, err := s.svc.Eula.ActivateVersion(ctx.Request().Context(), id, adminUser.Email)
if err != nil {
if errors.Is(err, eula.ErrVersionNotFound) {
return ctx.JSON(http.StatusNotFound, ErrorMessage{
Message: "EULA version not found",
})
}
if errors.Is(err, eula.ErrConcurrentActivation) {
return ctx.JSON(http.StatusConflict, ErrorMessage{
Message: "Concurrent activation conflict - another version was activated. Please refresh and try again.",
})
}
s.cfg.GetAuthLogger().Error("ActivateEulaVersion: failed to activate EULA version",
"error", err,
"version_id", id,
"activated_by", adminUser.Email)
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: fmt.Sprintf("Failed to activate EULA version: %v", err),
})
}
return ctx.JSON(http.StatusOK, convertVersionToAPI(version))
}
// ListEulaAgreements returns a paginated list of EULA agreements.
// GET /admin/eula/agreements
//
// Requires AdminService permission.
//
// Parameters:
// - page: Page number (1-based, default: 1)
// - page_size: Items per page (1-100, default: 20)
// - version_id: Optional filter by EULA version
// - user_id: Optional filter by Cognito subject ID
//
// Returns:
// - 200 OK with EulaAgreementList on success
// - 401 Unauthorized if not authenticated
// - 403 Forbidden if not authorized
// - 500 Internal Server Error on database errors
func (s *Controllers) ListEulaAgreements(ctx echo.Context, params ListEulaAgreementsParams) error {
// Apply defaults
page := int32(1)
pageSize := int32(20)
if params.Page != nil {
page = *params.Page
}
if params.PageSize != nil {
pageSize = *params.PageSize
}
input := &eula.ListAgreementsInput{
Page: page,
PageSize: pageSize,
}
// Convert optional UUID filter
if params.VersionId != nil {
versionID := uuid.UUID(*params.VersionId)
input.VersionID = &versionID
}
if params.UserId != nil {
input.UserID = params.UserId
}
result, err := s.svc.Eula.ListAgreements(ctx.Request().Context(), input)
if err != nil {
s.cfg.GetAuthLogger().Error("ListEulaAgreements: failed to list EULA agreements",
"error", err,
"page", page,
"page_size", pageSize)
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: fmt.Sprintf("ListEulaAgreements: failed to list EULA agreements: %v", err),
})
}
// Convert to API response
agreements := make([]EulaAgreement, len(result.Agreements))
for i, a := range result.Agreements {
agreements[i] = convertAgreementToAPI(a)
}
response := EulaAgreementList{
Agreements: agreements,
Total: int32(result.Total), // #nosec G115 -- Total is bounded by pageSize pagination
Page: page,
PageSize: pageSize,
HasMore: &result.HasMore,
}
return ctx.JSON(http.StatusOK, response)
}
// GetEulaCompliance returns a comprehensive compliance report.
// GET /admin/eula/compliance
//
// Requires AdminService permission.
// This endpoint queries Cognito for all users and compares with EULA agreements.
//
// Parameters:
// - page: Page number (1-based, default: 1)
// - page_size: Items per page (1-100, default: 50)
// - version_id: Optional specific version to report on (defaults to current)
// - agreed: Optional filter by agreement status
//
// Returns:
// - 200 OK with EulaComplianceReport on success
// - 401 Unauthorized if not authenticated
// - 403 Forbidden if not authorized
// - 404 Not Found if specified version doesn't exist or no current EULA
// - 502 Bad Gateway on Cognito errors
// - 500 Internal Server Error on database errors
func (s *Controllers) GetEulaCompliance(ctx echo.Context, params GetEulaComplianceParams) error {
// Initialize AWS config for Cognito
awsCfg, err := aws.GetAWSConfig(context.Background())
if err != nil {
s.cfg.GetAuthLogger().Error("GetEulaCompliance: failed to initialize AWS configuration",
"error", err)
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: fmt.Sprintf("GetEulaCompliance: failed to initialize AWS configuration: %v", err),
})
}
cognitoClient := cognitoidentityprovider.NewFromConfig(awsCfg)
userPoolID := s.cfg.GetAuthUserPoolID()
// Build service input from params
input := &eula.ComplianceReportInput{
Page: 1,
PageSize: 50,
}
if params.Page != nil {
input.Page = *params.Page
}
if params.PageSize != nil {
input.PageSize = *params.PageSize
}
if params.VersionId != nil {
// Convert openapi_types.UUID to uuid.UUID
versionID := uuid.UUID(*params.VersionId)
input.VersionID = &versionID
}
if params.Agreed != nil {
input.Agreed = params.Agreed
}
// Call service method
result, err := s.svc.Eula.GetComplianceReport(ctx.Request().Context(), cognitoClient, userPoolID, input)
if err != nil {
if errors.Is(err, eula.ErrNoCurrentVersion) {
return ctx.JSON(http.StatusNotFound, ErrorMessage{
Message: "GetEulaCompliance: no current EULA version configured",
})
}
if errors.Is(err, eula.ErrVersionNotFound) {
return ctx.JSON(http.StatusNotFound, ErrorMessage{
Message: "GetEulaCompliance: specified EULA version not found",
})
}
// Check if it's a Cognito error
if strings.Contains(err.Error(), "Cognito") || strings.Contains(err.Error(), "cognito") {
s.cfg.GetAuthLogger().Error("GetEulaCompliance: Cognito error during compliance report",
"error", err)
return ctx.JSON(http.StatusBadGateway, ErrorMessage{
Message: fmt.Sprintf("GetEulaCompliance: failed to fetch users from Cognito: %v", err),
})
}
s.cfg.GetAuthLogger().Error("GetEulaCompliance: failed to generate compliance report",
"error", err)
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: fmt.Sprintf("GetEulaCompliance: failed to generate compliance report: %v", err),
})
}
// Convert result to API response
response := convertComplianceReportToAPI(result)
return ctx.JSON(http.StatusOK, response)
}
// convertComplianceReportToAPI converts the service result to the API response type.
func convertComplianceReportToAPI(result *eula.ComplianceReportResult) EulaComplianceReport {
// Convert version to summary format
versionSummary := EulaVersionSummary{
Id: openapi_types.UUID(result.Version.ID),
Version: result.Version.Version,
Title: result.Version.Title,
IsCurrent: result.Version.Iscurrent,
}
// Handle nullable effectiveDate
if result.Version.Effectivedate.Valid {
versionSummary.EffectiveDate = nullable.NewNullableWithValue(result.Version.Effectivedate.Time)
}
// Convert summary
summary := EulaComplianceSummary{
TotalUsers: result.Summary.TotalUsers,
AgreedCount: result.Summary.AgreedCount,
NotAgreedCount: result.Summary.NotAgreedCount,
CompliancePercentage: result.Summary.CompliancePercentage,
}
// Convert users
users := make([]EulaComplianceUser, 0, len(result.Users))
for _, u := range result.Users {
user := EulaComplianceUser{
CognitoSubjectId: u.CognitoSubjectID,
CurrentEmail: u.CurrentEmail,
Agreed: u.Agreed,
}
if u.Email != nil {
user.Email = nullable.NewNullableWithValue(*u.Email)
}
if u.AgreedAt != nil {
user.AgreedAt = nullable.NewNullableWithValue(*u.AgreedAt)
}
if u.AgreedFromIP != nil {
user.AgreedFromIp = nullable.NewNullableWithValue(*u.AgreedFromIP)
}
users = append(users, user)
}
// Build pagination
pagination := EulaCompliancePagination{
Page: result.Page,
PageSize: result.PageSize,
TotalPages: result.TotalPages,
TotalItems: result.TotalItems,
}
return EulaComplianceReport{
EulaVersion: versionSummary,
Summary: summary,
Users: users,
Pagination: pagination,
}
}
// convertVersionToAPI converts a repository Eulaversion to the API EulaVersion type.
func convertVersionToAPI(v *repository.Eulaversion) EulaVersion {
version := EulaVersion{
Id: openapi_types.UUID(v.ID),
Version: v.Version,
Title: v.Title,
Content: v.Content,
CreatedAt: v.Createdat.Time,
CreatedBy: v.Createdby,
IsCurrent: v.Iscurrent,
}
// Handle nullable effectiveDate
if v.Effectivedate.Valid {
version.EffectiveDate = nullable.NewNullableWithValue(v.Effectivedate.Time)
}
if v.Activatedat.Valid {
version.ActivatedAt = nullable.NewNullableWithValue(v.Activatedat.Time)
}
if v.Activatedby != nil {
version.ActivatedBy = nullable.NewNullableWithValue(*v.Activatedby)
}
return version
}
// convertAgreementToAPI converts a repository ListEulaAgreementsRow to the API EulaAgreement type.
func convertAgreementToAPI(a *repository.ListEulaAgreementsRow) EulaAgreement {
var agreedAt time.Time
if a.Agreedat.Valid {
agreedAt = a.Agreedat.Time
}
return EulaAgreement{
Id: openapi_types.UUID(a.ID),
CognitoSubjectId: a.Cognitosubjectid,
UserEmail: a.Useremail,
EulaVersionId: openapi_types.UUID(a.Eulaversionid),
EulaVersion: a.Eulaversionstring,
AgreedAt: agreedAt,
AgreedFromIp: a.Agreedfromip,
}
}