Merged in feature/eula.part1 (pull request #206)
eula support * eula support * docs
This commit is contained in:
@@ -0,0 +1,520 @@
|
||||
// eulaAdminHandlers.go implements the admin EULA API handlers.
|
||||
// These endpoints handle EULA version management, agreement listing, and compliance reporting.
|
||||
package queryapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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 {
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
|
||||
Message: "Failed to list EULA versions",
|
||||
})
|
||||
}
|
||||
|
||||
// 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",
|
||||
})
|
||||
}
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
|
||||
Message: "Failed to create EULA version",
|
||||
})
|
||||
}
|
||||
|
||||
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",
|
||||
})
|
||||
}
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
|
||||
Message: "Failed to get EULA version",
|
||||
})
|
||||
}
|
||||
|
||||
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",
|
||||
})
|
||||
}
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
|
||||
Message: "Failed to update EULA version",
|
||||
})
|
||||
}
|
||||
|
||||
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.",
|
||||
})
|
||||
}
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
|
||||
Message: "Failed to activate EULA version",
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
|
||||
Message: "Failed to list EULA agreements",
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
|
||||
Message: "Failed to initialize AWS configuration",
|
||||
})
|
||||
}
|
||||
|
||||
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: "No current EULA version configured",
|
||||
})
|
||||
}
|
||||
if errors.Is(err, eula.ErrVersionNotFound) {
|
||||
return ctx.JSON(http.StatusNotFound, ErrorMessage{
|
||||
Message: "EULA version not found",
|
||||
})
|
||||
}
|
||||
// Check if it's a Cognito error
|
||||
if strings.Contains(err.Error(), "Cognito") || strings.Contains(err.Error(), "cognito") {
|
||||
return ctx.JSON(http.StatusBadGateway, ErrorMessage{
|
||||
Message: "Failed to fetch users from Cognito: " + err.Error(),
|
||||
})
|
||||
}
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
|
||||
Message: "Failed to generate compliance report",
|
||||
})
|
||||
}
|
||||
|
||||
// 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: openapi_types.Email(u.CurrentEmail),
|
||||
Agreed: u.Agreed,
|
||||
}
|
||||
|
||||
if u.Email != nil {
|
||||
user.Email = nullable.NewNullableWithValue(openapi_types.Email(*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: openapi_types.Email(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(openapi_types.Email(*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: openapi_types.Email(a.Useremail),
|
||||
EulaVersionId: openapi_types.UUID(a.Eulaversionid),
|
||||
EulaVersion: a.Eulaversionstring,
|
||||
AgreedAt: agreedAt,
|
||||
AgreedFromIp: a.Agreedfromip,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user