Merged in feature/eula.part1 (pull request #206)

eula support

* eula support

* docs
This commit is contained in:
Jay Brown
2026-01-22 18:17:27 +00:00
parent c10fa98d0a
commit 63c12a2f44
38 changed files with 15646 additions and 230 deletions
+7 -1
View File
@@ -21,6 +21,10 @@ func isPublicPath(requestPath string, config auth.ConfigProvider) bool {
return true
}
}
// Allow public access to current EULA version (GET /eula only)
if requestPath == "/eula" {
return true
}
return strings.HasPrefix(requestPath, "/swagger/") || strings.HasPrefix(requestPath, "/metrics")
}
@@ -63,7 +67,9 @@ func extractToken(c echo.Context, config auth.ConfigProvider) (string, error) {
// This enables users to discover their own roles/permissions before making other API calls.
func isAuthOnlyPath(requestPath string) bool {
authOnlyPaths := []string{
"/identity", // Users need to discover their roles without having any specific role
"/identity", // Users need to discover their roles without having any specific role
"/eula/status", // Users need to check their EULA agreement status
"/eula/agree", // Users need to be able to agree to the EULA
}
for _, path := range authOnlyPaths {
if requestPath == path {
+72
View File
@@ -0,0 +1,72 @@
package cognitoauth
import (
"os"
"strings"
"github.com/labstack/echo/v4"
)
// TestUserMiddleware injects test user identity from headers when DISABLE_AUTH=true.
//
// This middleware enables testing of user-authenticated endpoints without real JWT
// authentication. It only activates when the DISABLE_AUTH environment variable is
// set to "true". In production (DISABLE_AUTH=false or unset), this middleware does
// nothing and passes requests through unchanged.
//
// When active, it checks for the X-Test-User-Subject header. If present, it injects
// user identity into the request context, allowing handlers to retrieve user info
// via GetUserSubject() and GetUserInfo() as if a real JWT had been validated.
//
// Headers:
// - X-Test-User-Subject: The user's subject ID (required for injection)
// - X-Test-User-Email: The user's email (optional, defaults to <subject>@test.local)
//
// Usage in test scripts:
//
// curl -X GET http://localhost:8080/eula/status \
// -H "X-Test-User-Subject: test-user-123" \
// -H "X-Test-User-Email: testuser@example.com"
//
// This middleware should be registered early in the middleware chain so that
// subsequent handlers and middleware can access the injected user context.
//
// Returns:
// - echo.MiddlewareFunc: Middleware that conditionally injects test user identity
func TestUserMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Only active when DISABLE_AUTH=true (test mode)
disableAuth := os.Getenv("DISABLE_AUTH")
if strings.ToLower(disableAuth) != "true" {
return next(c)
}
// Check for test user header
subject := c.Request().Header.Get("X-Test-User-Subject")
if subject == "" {
// No test user header, continue normally
// (admin endpoints will use system user via their own logic)
return next(c)
}
// Get email from header or generate default
email := c.Request().Header.Get("X-Test-User-Email")
if email == "" {
email = subject + "@test.local"
}
// Inject user context (same structure as real auth middleware)
c.Set("user_claims", map[string]any{
"sub": subject,
"email": email,
})
c.Set("user_info", UserInfo{
Email: email,
Username: subject,
})
return next(c)
}
}
}
@@ -0,0 +1,11 @@
-- Rollback: Drop eulaAgreements and eulaVersions tables with indexes
DROP INDEX IF EXISTS idx_eulaagreements_user_time;
DROP INDEX IF EXISTS idx_eulaagreements_version_time;
DROP INDEX IF EXISTS idx_eulaagreements_versionid;
DROP INDEX IF EXISTS idx_eulaagreements_subjectid;
DROP TABLE IF EXISTS eulaAgreements;
DROP INDEX IF EXISTS idx_eulaversions_iscurrent;
DROP INDEX IF EXISTS idx_eulaversions_effectivedate;
DROP INDEX IF EXISTS idx_eulaversions_one_current;
DROP TABLE IF EXISTS eulaVersions;
@@ -0,0 +1,41 @@
-- Create eulaVersions table for storing EULA versions
CREATE TABLE eulaVersions (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
version VARCHAR(50) NOT NULL UNIQUE,
title TEXT NOT NULL,
content TEXT NOT NULL,
effectiveDate TIMESTAMPTZ NOT NULL,
createdAt TIMESTAMPTZ NOT NULL DEFAULT NOW(),
createdBy VARCHAR(320) NOT NULL,
isCurrent BOOLEAN NOT NULL DEFAULT FALSE,
activatedAt TIMESTAMPTZ,
activatedBy VARCHAR(320)
);
-- Partial unique index enforces only ONE row can have isCurrent=true at any time
CREATE UNIQUE INDEX idx_eulaversions_one_current ON eulaVersions(isCurrent) WHERE isCurrent = TRUE;
-- Index for quick lookup of current version
CREATE INDEX idx_eulaversions_effectivedate ON eulaVersions(effectiveDate DESC);
-- Index for isCurrent lookup (used in GetCurrentEulaVersion)
CREATE INDEX idx_eulaversions_iscurrent ON eulaVersions(isCurrent);
-- Create eulaAgreements table for tracking user agreements
CREATE TABLE eulaAgreements (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
cognitoSubjectId VARCHAR(256) NOT NULL,
userEmail VARCHAR(320) NOT NULL,
eulaVersionId uuid NOT NULL REFERENCES eulaVersions(id) ON DELETE RESTRICT,
agreedAt TIMESTAMPTZ NOT NULL DEFAULT NOW(),
agreedFromIp VARCHAR(45) NOT NULL,
UNIQUE(cognitoSubjectId, eulaVersionId)
);
-- Basic indexes
CREATE INDEX idx_eulaagreements_subjectid ON eulaAgreements(cognitoSubjectId);
CREATE INDEX idx_eulaagreements_versionid ON eulaAgreements(eulaVersionId);
-- Composite indexes for common read patterns
CREATE INDEX idx_eulaagreements_version_time ON eulaAgreements(eulaVersionId, agreedAt DESC);
CREATE INDEX idx_eulaagreements_user_time ON eulaAgreements(cognitoSubjectId, agreedAt DESC);
+110
View File
@@ -0,0 +1,110 @@
-- name: CreateEulaVersion :one
-- Creates a new EULA version
INSERT INTO eulaVersions (version, title, content, effectiveDate, createdBy)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy;
-- name: GetEulaVersionById :one
-- Retrieves a specific EULA version by its ID
SELECT id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy
FROM eulaVersions
WHERE id = $1;
-- name: GetCurrentEulaVersion :one
-- Retrieves the current active EULA version
SELECT id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy
FROM eulaVersions
WHERE isCurrent = TRUE;
-- name: ListEulaVersions :many
-- Lists all EULA versions with pagination, ordered by createdAt descending
SELECT id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy
FROM eulaVersions
ORDER BY createdAt DESC
LIMIT $1 OFFSET $2;
-- name: CountEulaVersions :one
-- Counts total number of EULA versions
SELECT COUNT(*) as total
FROM eulaVersions;
-- name: UpdateEulaVersionMetadata :one
-- Updates only the metadata (title and effectiveDate) of an EULA version
UPDATE eulaVersions
SET title = $2, effectiveDate = $3
WHERE id = $1
RETURNING id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy;
-- name: ClearCurrentEulaVersions :exec
-- Clears the isCurrent flag from all versions (does not clear activatedAt/By for audit trail)
UPDATE eulaVersions
SET isCurrent = FALSE
WHERE isCurrent = TRUE;
-- name: SetEulaVersionCurrent :one
-- Sets a specific version as the current active version with audit fields
UPDATE eulaVersions
SET isCurrent = TRUE, activatedAt = NOW(), activatedBy = $2
WHERE id = $1
RETURNING id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy;
-- name: CreateEulaAgreement :one
-- Records a user's agreement to an EULA version
INSERT INTO eulaAgreements (cognitoSubjectId, userEmail, eulaVersionId, agreedFromIp)
VALUES ($1, $2, $3, $4)
RETURNING id, cognitoSubjectId, userEmail, eulaVersionId, agreedAt, agreedFromIp;
-- name: GetUserEulaAgreement :one
-- Checks if a user has agreed to a specific EULA version
SELECT id, cognitoSubjectId, userEmail, eulaVersionId, agreedAt, agreedFromIp
FROM eulaAgreements
WHERE cognitoSubjectId = $1 AND eulaVersionId = $2;
-- name: GetUserCurrentEulaAgreement :one
-- Checks if a user has agreed to the current EULA version
SELECT a.id, a.cognitoSubjectId, a.userEmail, a.eulaVersionId, a.agreedAt, a.agreedFromIp
FROM eulaAgreements a
JOIN eulaVersions v ON a.eulaVersionId = v.id
WHERE a.cognitoSubjectId = $1 AND v.isCurrent = TRUE;
-- name: ListEulaAgreements :many
-- Lists agreements with optional filtering by version_id or user_id
-- Pass NULL for filters to skip them
SELECT a.id, a.cognitoSubjectId, a.userEmail, a.eulaVersionId, a.agreedAt, a.agreedFromIp,
v.version as eulaVersionString
FROM eulaAgreements a
JOIN eulaVersions v ON a.eulaVersionId = v.id
WHERE (sqlc.narg(version_id)::uuid IS NULL OR a.eulaVersionId = sqlc.narg(version_id)::uuid)
AND (sqlc.narg(user_id)::text IS NULL OR a.cognitoSubjectId = sqlc.narg(user_id)::text)
ORDER BY a.agreedAt DESC
LIMIT $1 OFFSET $2;
-- name: CountEulaAgreements :one
-- Counts agreements with optional filtering (matches ListEulaAgreements filters)
SELECT COUNT(*) as total
FROM eulaAgreements a
WHERE (sqlc.narg(version_id)::uuid IS NULL OR a.eulaVersionId = sqlc.narg(version_id)::uuid)
AND (sqlc.narg(user_id)::text IS NULL OR a.cognitoSubjectId = sqlc.narg(user_id)::text);
-- name: ListEulaAgreementsByUser :many
-- Gets all EULA versions a specific user has agreed to (user's complete history)
SELECT a.id, a.cognitoSubjectId, a.userEmail, a.eulaVersionId, a.agreedAt, a.agreedFromIp,
v.version as eulaVersionString, v.title as eulaVersionTitle
FROM eulaAgreements a
JOIN eulaVersions v ON a.eulaVersionId = v.id
WHERE a.cognitoSubjectId = $1
ORDER BY a.agreedAt DESC;
-- name: CountEulaAgreementsByVersion :one
-- Counts how many users have agreed to a specific EULA version
SELECT COUNT(*) as count
FROM eulaAgreements
WHERE eulaVersionId = $1;
-- name: ListAgreedUsersForVersion :many
-- Lists all users who have agreed to a specific EULA version
SELECT id, cognitoSubjectId, userEmail, eulaVersionId, agreedAt, agreedFromIp
FROM eulaAgreements
WHERE eulaVersionId = $1
ORDER BY agreedAt DESC
LIMIT $2 OFFSET $3;
+582
View File
@@ -0,0 +1,582 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: eula.sql
package repository
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const clearCurrentEulaVersions = `-- name: ClearCurrentEulaVersions :exec
UPDATE eulaVersions
SET isCurrent = FALSE
WHERE isCurrent = TRUE
`
// Clears the isCurrent flag from all versions (does not clear activatedAt/By for audit trail)
//
// UPDATE eulaVersions
// SET isCurrent = FALSE
// WHERE isCurrent = TRUE
func (q *Queries) ClearCurrentEulaVersions(ctx context.Context) error {
_, err := q.db.Exec(ctx, clearCurrentEulaVersions)
return err
}
const countEulaAgreements = `-- name: CountEulaAgreements :one
SELECT COUNT(*) as total
FROM eulaAgreements a
WHERE ($1::uuid IS NULL OR a.eulaVersionId = $1::uuid)
AND ($2::text IS NULL OR a.cognitoSubjectId = $2::text)
`
type CountEulaAgreementsParams struct {
VersionID *uuid.UUID `db:"version_id"`
UserID *string `db:"user_id"`
}
// Counts agreements with optional filtering (matches ListEulaAgreements filters)
//
// SELECT COUNT(*) as total
// FROM eulaAgreements a
// WHERE ($1::uuid IS NULL OR a.eulaVersionId = $1::uuid)
// AND ($2::text IS NULL OR a.cognitoSubjectId = $2::text)
func (q *Queries) CountEulaAgreements(ctx context.Context, arg *CountEulaAgreementsParams) (int64, error) {
row := q.db.QueryRow(ctx, countEulaAgreements, arg.VersionID, arg.UserID)
var total int64
err := row.Scan(&total)
return total, err
}
const countEulaAgreementsByVersion = `-- name: CountEulaAgreementsByVersion :one
SELECT COUNT(*) as count
FROM eulaAgreements
WHERE eulaVersionId = $1
`
// Counts how many users have agreed to a specific EULA version
//
// SELECT COUNT(*) as count
// FROM eulaAgreements
// WHERE eulaVersionId = $1
func (q *Queries) CountEulaAgreementsByVersion(ctx context.Context, eulaversionid uuid.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countEulaAgreementsByVersion, eulaversionid)
var count int64
err := row.Scan(&count)
return count, err
}
const countEulaVersions = `-- name: CountEulaVersions :one
SELECT COUNT(*) as total
FROM eulaVersions
`
// Counts total number of EULA versions
//
// SELECT COUNT(*) as total
// FROM eulaVersions
func (q *Queries) CountEulaVersions(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, countEulaVersions)
var total int64
err := row.Scan(&total)
return total, err
}
const createEulaAgreement = `-- name: CreateEulaAgreement :one
INSERT INTO eulaAgreements (cognitoSubjectId, userEmail, eulaVersionId, agreedFromIp)
VALUES ($1, $2, $3, $4)
RETURNING id, cognitoSubjectId, userEmail, eulaVersionId, agreedAt, agreedFromIp
`
type CreateEulaAgreementParams struct {
Cognitosubjectid string `db:"cognitosubjectid"`
Useremail string `db:"useremail"`
Eulaversionid uuid.UUID `db:"eulaversionid"`
Agreedfromip string `db:"agreedfromip"`
}
// Records a user's agreement to an EULA version
//
// INSERT INTO eulaAgreements (cognitoSubjectId, userEmail, eulaVersionId, agreedFromIp)
// VALUES ($1, $2, $3, $4)
// RETURNING id, cognitoSubjectId, userEmail, eulaVersionId, agreedAt, agreedFromIp
func (q *Queries) CreateEulaAgreement(ctx context.Context, arg *CreateEulaAgreementParams) (*Eulaagreement, error) {
row := q.db.QueryRow(ctx, createEulaAgreement,
arg.Cognitosubjectid,
arg.Useremail,
arg.Eulaversionid,
arg.Agreedfromip,
)
var i Eulaagreement
err := row.Scan(
&i.ID,
&i.Cognitosubjectid,
&i.Useremail,
&i.Eulaversionid,
&i.Agreedat,
&i.Agreedfromip,
)
return &i, err
}
const createEulaVersion = `-- name: CreateEulaVersion :one
INSERT INTO eulaVersions (version, title, content, effectiveDate, createdBy)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy
`
type CreateEulaVersionParams struct {
Version string `db:"version"`
Title string `db:"title"`
Content string `db:"content"`
Effectivedate pgtype.Timestamptz `db:"effectivedate"`
Createdby string `db:"createdby"`
}
// Creates a new EULA version
//
// INSERT INTO eulaVersions (version, title, content, effectiveDate, createdBy)
// VALUES ($1, $2, $3, $4, $5)
// RETURNING id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy
func (q *Queries) CreateEulaVersion(ctx context.Context, arg *CreateEulaVersionParams) (*Eulaversion, error) {
row := q.db.QueryRow(ctx, createEulaVersion,
arg.Version,
arg.Title,
arg.Content,
arg.Effectivedate,
arg.Createdby,
)
var i Eulaversion
err := row.Scan(
&i.ID,
&i.Version,
&i.Title,
&i.Content,
&i.Effectivedate,
&i.Createdat,
&i.Createdby,
&i.Iscurrent,
&i.Activatedat,
&i.Activatedby,
)
return &i, err
}
const getCurrentEulaVersion = `-- name: GetCurrentEulaVersion :one
SELECT id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy
FROM eulaVersions
WHERE isCurrent = TRUE
`
// Retrieves the current active EULA version
//
// SELECT id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy
// FROM eulaVersions
// WHERE isCurrent = TRUE
func (q *Queries) GetCurrentEulaVersion(ctx context.Context) (*Eulaversion, error) {
row := q.db.QueryRow(ctx, getCurrentEulaVersion)
var i Eulaversion
err := row.Scan(
&i.ID,
&i.Version,
&i.Title,
&i.Content,
&i.Effectivedate,
&i.Createdat,
&i.Createdby,
&i.Iscurrent,
&i.Activatedat,
&i.Activatedby,
)
return &i, err
}
const getEulaVersionById = `-- name: GetEulaVersionById :one
SELECT id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy
FROM eulaVersions
WHERE id = $1
`
// Retrieves a specific EULA version by its ID
//
// SELECT id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy
// FROM eulaVersions
// WHERE id = $1
func (q *Queries) GetEulaVersionById(ctx context.Context, id uuid.UUID) (*Eulaversion, error) {
row := q.db.QueryRow(ctx, getEulaVersionById, id)
var i Eulaversion
err := row.Scan(
&i.ID,
&i.Version,
&i.Title,
&i.Content,
&i.Effectivedate,
&i.Createdat,
&i.Createdby,
&i.Iscurrent,
&i.Activatedat,
&i.Activatedby,
)
return &i, err
}
const getUserCurrentEulaAgreement = `-- name: GetUserCurrentEulaAgreement :one
SELECT a.id, a.cognitoSubjectId, a.userEmail, a.eulaVersionId, a.agreedAt, a.agreedFromIp
FROM eulaAgreements a
JOIN eulaVersions v ON a.eulaVersionId = v.id
WHERE a.cognitoSubjectId = $1 AND v.isCurrent = TRUE
`
// Checks if a user has agreed to the current EULA version
//
// SELECT a.id, a.cognitoSubjectId, a.userEmail, a.eulaVersionId, a.agreedAt, a.agreedFromIp
// FROM eulaAgreements a
// JOIN eulaVersions v ON a.eulaVersionId = v.id
// WHERE a.cognitoSubjectId = $1 AND v.isCurrent = TRUE
func (q *Queries) GetUserCurrentEulaAgreement(ctx context.Context, cognitosubjectid string) (*Eulaagreement, error) {
row := q.db.QueryRow(ctx, getUserCurrentEulaAgreement, cognitosubjectid)
var i Eulaagreement
err := row.Scan(
&i.ID,
&i.Cognitosubjectid,
&i.Useremail,
&i.Eulaversionid,
&i.Agreedat,
&i.Agreedfromip,
)
return &i, err
}
const getUserEulaAgreement = `-- name: GetUserEulaAgreement :one
SELECT id, cognitoSubjectId, userEmail, eulaVersionId, agreedAt, agreedFromIp
FROM eulaAgreements
WHERE cognitoSubjectId = $1 AND eulaVersionId = $2
`
type GetUserEulaAgreementParams struct {
Cognitosubjectid string `db:"cognitosubjectid"`
Eulaversionid uuid.UUID `db:"eulaversionid"`
}
// Checks if a user has agreed to a specific EULA version
//
// SELECT id, cognitoSubjectId, userEmail, eulaVersionId, agreedAt, agreedFromIp
// FROM eulaAgreements
// WHERE cognitoSubjectId = $1 AND eulaVersionId = $2
func (q *Queries) GetUserEulaAgreement(ctx context.Context, arg *GetUserEulaAgreementParams) (*Eulaagreement, error) {
row := q.db.QueryRow(ctx, getUserEulaAgreement, arg.Cognitosubjectid, arg.Eulaversionid)
var i Eulaagreement
err := row.Scan(
&i.ID,
&i.Cognitosubjectid,
&i.Useremail,
&i.Eulaversionid,
&i.Agreedat,
&i.Agreedfromip,
)
return &i, err
}
const listAgreedUsersForVersion = `-- name: ListAgreedUsersForVersion :many
SELECT id, cognitoSubjectId, userEmail, eulaVersionId, agreedAt, agreedFromIp
FROM eulaAgreements
WHERE eulaVersionId = $1
ORDER BY agreedAt DESC
LIMIT $2 OFFSET $3
`
type ListAgreedUsersForVersionParams struct {
Eulaversionid uuid.UUID `db:"eulaversionid"`
Limit int64 `db:"limit"`
Offset int64 `db:"offset"`
}
// Lists all users who have agreed to a specific EULA version
//
// SELECT id, cognitoSubjectId, userEmail, eulaVersionId, agreedAt, agreedFromIp
// FROM eulaAgreements
// WHERE eulaVersionId = $1
// ORDER BY agreedAt DESC
// LIMIT $2 OFFSET $3
func (q *Queries) ListAgreedUsersForVersion(ctx context.Context, arg *ListAgreedUsersForVersionParams) ([]*Eulaagreement, error) {
rows, err := q.db.Query(ctx, listAgreedUsersForVersion, arg.Eulaversionid, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []*Eulaagreement{}
for rows.Next() {
var i Eulaagreement
if err := rows.Scan(
&i.ID,
&i.Cognitosubjectid,
&i.Useremail,
&i.Eulaversionid,
&i.Agreedat,
&i.Agreedfromip,
); err != nil {
return nil, err
}
items = append(items, &i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listEulaAgreements = `-- name: ListEulaAgreements :many
SELECT a.id, a.cognitoSubjectId, a.userEmail, a.eulaVersionId, a.agreedAt, a.agreedFromIp,
v.version as eulaVersionString
FROM eulaAgreements a
JOIN eulaVersions v ON a.eulaVersionId = v.id
WHERE ($3::uuid IS NULL OR a.eulaVersionId = $3::uuid)
AND ($4::text IS NULL OR a.cognitoSubjectId = $4::text)
ORDER BY a.agreedAt DESC
LIMIT $1 OFFSET $2
`
type ListEulaAgreementsParams struct {
Limit int64 `db:"limit"`
Offset int64 `db:"offset"`
VersionID *uuid.UUID `db:"version_id"`
UserID *string `db:"user_id"`
}
type ListEulaAgreementsRow struct {
ID uuid.UUID `db:"id"`
Cognitosubjectid string `db:"cognitosubjectid"`
Useremail string `db:"useremail"`
Eulaversionid uuid.UUID `db:"eulaversionid"`
Agreedat pgtype.Timestamptz `db:"agreedat"`
Agreedfromip string `db:"agreedfromip"`
Eulaversionstring string `db:"eulaversionstring"`
}
// Lists agreements with optional filtering by version_id or user_id
// Pass NULL for filters to skip them
//
// SELECT a.id, a.cognitoSubjectId, a.userEmail, a.eulaVersionId, a.agreedAt, a.agreedFromIp,
// v.version as eulaVersionString
// FROM eulaAgreements a
// JOIN eulaVersions v ON a.eulaVersionId = v.id
// WHERE ($3::uuid IS NULL OR a.eulaVersionId = $3::uuid)
// AND ($4::text IS NULL OR a.cognitoSubjectId = $4::text)
// ORDER BY a.agreedAt DESC
// LIMIT $1 OFFSET $2
func (q *Queries) ListEulaAgreements(ctx context.Context, arg *ListEulaAgreementsParams) ([]*ListEulaAgreementsRow, error) {
rows, err := q.db.Query(ctx, listEulaAgreements,
arg.Limit,
arg.Offset,
arg.VersionID,
arg.UserID,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []*ListEulaAgreementsRow{}
for rows.Next() {
var i ListEulaAgreementsRow
if err := rows.Scan(
&i.ID,
&i.Cognitosubjectid,
&i.Useremail,
&i.Eulaversionid,
&i.Agreedat,
&i.Agreedfromip,
&i.Eulaversionstring,
); err != nil {
return nil, err
}
items = append(items, &i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listEulaAgreementsByUser = `-- name: ListEulaAgreementsByUser :many
SELECT a.id, a.cognitoSubjectId, a.userEmail, a.eulaVersionId, a.agreedAt, a.agreedFromIp,
v.version as eulaVersionString, v.title as eulaVersionTitle
FROM eulaAgreements a
JOIN eulaVersions v ON a.eulaVersionId = v.id
WHERE a.cognitoSubjectId = $1
ORDER BY a.agreedAt DESC
`
type ListEulaAgreementsByUserRow struct {
ID uuid.UUID `db:"id"`
Cognitosubjectid string `db:"cognitosubjectid"`
Useremail string `db:"useremail"`
Eulaversionid uuid.UUID `db:"eulaversionid"`
Agreedat pgtype.Timestamptz `db:"agreedat"`
Agreedfromip string `db:"agreedfromip"`
Eulaversionstring string `db:"eulaversionstring"`
Eulaversiontitle string `db:"eulaversiontitle"`
}
// Gets all EULA versions a specific user has agreed to (user's complete history)
//
// SELECT a.id, a.cognitoSubjectId, a.userEmail, a.eulaVersionId, a.agreedAt, a.agreedFromIp,
// v.version as eulaVersionString, v.title as eulaVersionTitle
// FROM eulaAgreements a
// JOIN eulaVersions v ON a.eulaVersionId = v.id
// WHERE a.cognitoSubjectId = $1
// ORDER BY a.agreedAt DESC
func (q *Queries) ListEulaAgreementsByUser(ctx context.Context, cognitosubjectid string) ([]*ListEulaAgreementsByUserRow, error) {
rows, err := q.db.Query(ctx, listEulaAgreementsByUser, cognitosubjectid)
if err != nil {
return nil, err
}
defer rows.Close()
items := []*ListEulaAgreementsByUserRow{}
for rows.Next() {
var i ListEulaAgreementsByUserRow
if err := rows.Scan(
&i.ID,
&i.Cognitosubjectid,
&i.Useremail,
&i.Eulaversionid,
&i.Agreedat,
&i.Agreedfromip,
&i.Eulaversionstring,
&i.Eulaversiontitle,
); err != nil {
return nil, err
}
items = append(items, &i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listEulaVersions = `-- name: ListEulaVersions :many
SELECT id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy
FROM eulaVersions
ORDER BY createdAt DESC
LIMIT $1 OFFSET $2
`
type ListEulaVersionsParams struct {
Limit int64 `db:"limit"`
Offset int64 `db:"offset"`
}
// Lists all EULA versions with pagination, ordered by createdAt descending
//
// SELECT id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy
// FROM eulaVersions
// ORDER BY createdAt DESC
// LIMIT $1 OFFSET $2
func (q *Queries) ListEulaVersions(ctx context.Context, arg *ListEulaVersionsParams) ([]*Eulaversion, error) {
rows, err := q.db.Query(ctx, listEulaVersions, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []*Eulaversion{}
for rows.Next() {
var i Eulaversion
if err := rows.Scan(
&i.ID,
&i.Version,
&i.Title,
&i.Content,
&i.Effectivedate,
&i.Createdat,
&i.Createdby,
&i.Iscurrent,
&i.Activatedat,
&i.Activatedby,
); err != nil {
return nil, err
}
items = append(items, &i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setEulaVersionCurrent = `-- name: SetEulaVersionCurrent :one
UPDATE eulaVersions
SET isCurrent = TRUE, activatedAt = NOW(), activatedBy = $2
WHERE id = $1
RETURNING id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy
`
type SetEulaVersionCurrentParams struct {
ID uuid.UUID `db:"id"`
Activatedby *string `db:"activatedby"`
}
// Sets a specific version as the current active version with audit fields
//
// UPDATE eulaVersions
// SET isCurrent = TRUE, activatedAt = NOW(), activatedBy = $2
// WHERE id = $1
// RETURNING id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy
func (q *Queries) SetEulaVersionCurrent(ctx context.Context, arg *SetEulaVersionCurrentParams) (*Eulaversion, error) {
row := q.db.QueryRow(ctx, setEulaVersionCurrent, arg.ID, arg.Activatedby)
var i Eulaversion
err := row.Scan(
&i.ID,
&i.Version,
&i.Title,
&i.Content,
&i.Effectivedate,
&i.Createdat,
&i.Createdby,
&i.Iscurrent,
&i.Activatedat,
&i.Activatedby,
)
return &i, err
}
const updateEulaVersionMetadata = `-- name: UpdateEulaVersionMetadata :one
UPDATE eulaVersions
SET title = $2, effectiveDate = $3
WHERE id = $1
RETURNING id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy
`
type UpdateEulaVersionMetadataParams struct {
ID uuid.UUID `db:"id"`
Title string `db:"title"`
Effectivedate pgtype.Timestamptz `db:"effectivedate"`
}
// Updates only the metadata (title and effectiveDate) of an EULA version
//
// UPDATE eulaVersions
// SET title = $2, effectiveDate = $3
// WHERE id = $1
// RETURNING id, version, title, content, effectiveDate, createdAt, createdBy, isCurrent, activatedAt, activatedBy
func (q *Queries) UpdateEulaVersionMetadata(ctx context.Context, arg *UpdateEulaVersionMetadataParams) (*Eulaversion, error) {
row := q.db.QueryRow(ctx, updateEulaVersionMetadata, arg.ID, arg.Title, arg.Effectivedate)
var i Eulaversion
err := row.Scan(
&i.ID,
&i.Version,
&i.Title,
&i.Content,
&i.Effectivedate,
&i.Createdat,
&i.Createdby,
&i.Iscurrent,
&i.Activatedat,
&i.Activatedby,
)
return &i, err
}
+22
View File
@@ -524,6 +524,28 @@ type Documentupload struct {
FolderID *uuid.UUID `db:"folder_id"`
}
type Eulaagreement struct {
ID uuid.UUID `db:"id"`
Cognitosubjectid string `db:"cognitosubjectid"`
Useremail string `db:"useremail"`
Eulaversionid uuid.UUID `db:"eulaversionid"`
Agreedat pgtype.Timestamptz `db:"agreedat"`
Agreedfromip string `db:"agreedfromip"`
}
type Eulaversion struct {
ID uuid.UUID `db:"id"`
Version string `db:"version"`
Title string `db:"title"`
Content string `db:"content"`
Effectivedate pgtype.Timestamptz `db:"effectivedate"`
Createdat pgtype.Timestamptz `db:"createdat"`
Createdby string `db:"createdby"`
Iscurrent bool `db:"iscurrent"`
Activatedat pgtype.Timestamptz `db:"activatedat"`
Activatedby *string `db:"activatedby"`
}
// Virtual folder hierarchy for document organization. Database-only - has no direct relationship to S3 storage locations.
type Folder struct {
ID uuid.UUID `db:"id"`
+769
View File
@@ -0,0 +1,769 @@
// Package eula provides EULA version management and user agreement tracking.
// It handles creating/activating EULA versions and recording user consent with
// associated metadata like timestamp and IP address.
package eula
import (
"context"
"errors"
"fmt"
"math"
"sort"
"time"
"queryorchestration/internal/database/repository"
"queryorchestration/internal/serviceconfig"
"queryorchestration/internal/usermanagement"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// ErrNoCurrentVersion indicates no EULA version is currently active.
var ErrNoCurrentVersion = errors.New("no current EULA version configured")
// ErrVersionNotFound indicates the requested EULA version does not exist.
var ErrVersionNotFound = errors.New("EULA version not found")
// ErrAlreadyAgreed indicates the user has already agreed to the specified EULA version.
var ErrAlreadyAgreed = errors.New("user has already agreed to this EULA version")
// ErrConcurrentActivation indicates another activation occurred during the transaction.
var ErrConcurrentActivation = errors.New("concurrent activation conflict - another version was activated")
// Service provides EULA management operations including version creation,
// activation, and user agreement tracking.
type Service struct {
cfg serviceconfig.ConfigProvider
}
// New creates a new EULA service instance.
//
// Parameters:
// - cfg: Configuration provider for database access
//
// Returns:
// - A new Service instance
func New(cfg serviceconfig.ConfigProvider) *Service {
return &Service{cfg: cfg}
}
// GetCurrentVersion retrieves the current active EULA version.
//
// Returns:
// - The current EULA version, or nil with ErrNoCurrentVersion if none is active
// - An error if the database query fails
func (s *Service) GetCurrentVersion(ctx context.Context) (*repository.Eulaversion, error) {
version, err := s.cfg.GetDBQueries().GetCurrentEulaVersion(ctx)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNoCurrentVersion
}
return nil, fmt.Errorf("failed to get current EULA version: %w", err)
}
return version, nil
}
// CreateVersionInput contains the parameters for creating a new EULA version.
type CreateVersionInput struct {
Version string // Unique version string (e.g., "1.0", "2024-01")
Title string // Human-readable title
Content string // Full EULA text in Markdown format
EffectiveDate *time.Time // When this version becomes effective (optional)
CreatedBy string // Email of admin who created this version
}
// CreateVersion creates a new EULA version.
//
// Parameters:
// - ctx: Context for cancellation and timeout
// - input: CreateVersionInput with version details
//
// Returns:
// - The created EULA version
// - An error if validation fails or version string already exists
func (s *Service) CreateVersion(ctx context.Context, input *CreateVersionInput) (*repository.Eulaversion, error) {
if input.Version == "" {
return nil, fmt.Errorf("version string cannot be empty")
}
if input.Title == "" {
return nil, fmt.Errorf("title cannot be empty")
}
if input.Content == "" {
return nil, fmt.Errorf("content cannot be empty")
}
if input.CreatedBy == "" {
return nil, fmt.Errorf("createdBy cannot be empty")
}
// Convert time.Time to pgtype.Timestamptz (optional - may be nil)
var effectiveDate pgtype.Timestamptz
if input.EffectiveDate != nil {
effectiveDate = pgtype.Timestamptz{
Time: *input.EffectiveDate,
Valid: true,
}
}
version, err := s.cfg.GetDBQueries().CreateEulaVersion(ctx, &repository.CreateEulaVersionParams{
Version: input.Version,
Title: input.Title,
Content: input.Content,
Effectivedate: effectiveDate,
Createdby: input.CreatedBy,
})
if err != nil {
return nil, fmt.Errorf("failed to create EULA version: %w", err)
}
return version, nil
}
// ListVersionsInput contains pagination parameters for listing EULA versions.
type ListVersionsInput struct {
Page int32 // Page number (1-based)
PageSize int32 // Items per page
}
// ListVersionsResult contains the paginated list of EULA versions.
type ListVersionsResult struct {
Versions []*repository.Eulaversion
Total int64
HasMore bool
}
// ListVersions retrieves a paginated list of all EULA versions.
//
// Parameters:
// - ctx: Context for cancellation and timeout
// - input: Pagination parameters
//
// Returns:
// - ListVersionsResult with versions and pagination metadata
// - An error if the database query fails
func (s *Service) ListVersions(ctx context.Context, input *ListVersionsInput) (*ListVersionsResult, error) {
if input.Page < 1 {
input.Page = 1
}
if input.PageSize < 1 {
input.PageSize = 20
}
if input.PageSize > 100 {
input.PageSize = 100
}
offset := int64((input.Page - 1) * input.PageSize)
limit := int64(input.PageSize)
versions, err := s.cfg.GetDBQueries().ListEulaVersions(ctx, &repository.ListEulaVersionsParams{
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, fmt.Errorf("failed to list EULA versions: %w", err)
}
total, err := s.cfg.GetDBQueries().CountEulaVersions(ctx)
if err != nil {
return nil, fmt.Errorf("failed to count EULA versions: %w", err)
}
return &ListVersionsResult{
Versions: versions,
Total: total,
HasMore: offset+int64(len(versions)) < total,
}, nil
}
// GetVersionByID retrieves a specific EULA version by its ID.
//
// Parameters:
// - ctx: Context for cancellation and timeout
// - id: The EULA version UUID
//
// Returns:
// - The EULA version, or nil with ErrVersionNotFound if not found
// - An error if the database query fails
func (s *Service) GetVersionByID(ctx context.Context, id uuid.UUID) (*repository.Eulaversion, error) {
version, err := s.cfg.GetDBQueries().GetEulaVersionById(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrVersionNotFound
}
return nil, fmt.Errorf("failed to get EULA version: %w", err)
}
return version, nil
}
// UpdateVersionInput contains the parameters for updating EULA version metadata.
// Only title and effective date can be updated; content is immutable.
type UpdateVersionInput struct {
Title *string // New title (nil to keep unchanged)
EffectiveDate *time.Time // New effective date (nil to keep unchanged)
}
// UpdateVersion updates the metadata (title and effective date) of an EULA version.
// Content cannot be modified to maintain audit integrity.
//
// Parameters:
// - ctx: Context for cancellation and timeout
// - id: The EULA version UUID
// - input: Fields to update
//
// Returns:
// - The updated EULA version
// - ErrVersionNotFound if version doesn't exist
// - An error if the database update fails
func (s *Service) UpdateVersion(ctx context.Context, id uuid.UUID, input *UpdateVersionInput) (*repository.Eulaversion, error) {
// First get the existing version
existing, err := s.GetVersionByID(ctx, id)
if err != nil {
return nil, err
}
// Apply updates
title := existing.Title
if input.Title != nil {
title = *input.Title
}
effectiveDate := existing.Effectivedate
if input.EffectiveDate != nil {
effectiveDate = pgtype.Timestamptz{
Time: *input.EffectiveDate,
Valid: true,
}
}
version, err := s.cfg.GetDBQueries().UpdateEulaVersionMetadata(ctx, &repository.UpdateEulaVersionMetadataParams{
ID: id,
Title: title,
Effectivedate: effectiveDate,
})
if err != nil {
return nil, fmt.Errorf("failed to update EULA version: %w", err)
}
return version, nil
}
// ActivateVersion sets the specified version as the current active EULA.
// Uses a database transaction to atomically clear the previous current flag and set the new one.
// The partial unique index ensures only one version can be current at any time.
//
// Parameters:
// - ctx: Context for cancellation and timeout
// - id: The EULA version UUID to activate
// - activatedBy: Email of admin performing the activation
//
// Returns:
// - The activated EULA version with isCurrent=true, activatedAt, and activatedBy populated
// - ErrVersionNotFound if version doesn't exist
// - ErrConcurrentActivation if another activation occurred during the transaction
// - An error if the database operations fail
func (s *Service) ActivateVersion(ctx context.Context, id uuid.UUID, activatedBy string) (*repository.Eulaversion, error) {
// First verify the version exists
_, err := s.GetVersionByID(ctx, id)
if err != nil {
return nil, err
}
// Begin transaction
tx, err := s.cfg.GetDBPool().Begin(ctx)
if err != nil {
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
_ = tx.Rollback(ctx)
}()
queries := s.cfg.GetDBQueries().WithTx(tx)
// Clear all current flags
err = queries.ClearCurrentEulaVersions(ctx)
if err != nil {
return nil, fmt.Errorf("failed to clear current EULA versions: %w", err)
}
// Set the new current version
activatedByPtr := &activatedBy
version, err := queries.SetEulaVersionCurrent(ctx, &repository.SetEulaVersionCurrentParams{
ID: id,
Activatedby: activatedByPtr,
})
if err != nil {
// The partial unique index will cause a conflict if another transaction
// already set a different version as current
return nil, fmt.Errorf("failed to activate EULA version: %w", err)
}
// Commit transaction
err = tx.Commit(ctx)
if err != nil {
return nil, fmt.Errorf("failed to commit activation: %w", err)
}
return version, nil
}
// RecordAgreementInput contains the parameters for recording a user's EULA agreement.
type RecordAgreementInput struct {
CognitoSubjectID string // User's Cognito subject ID
UserEmail string // User's email at time of agreement
EulaVersionID uuid.UUID // The EULA version being agreed to
IPAddress string // IP address from which the user agreed
}
// RecordAgreementResult contains the result of recording an agreement.
type RecordAgreementResult struct {
Agreement *repository.Eulaagreement
AlreadyAgreed bool // True if user had already agreed (returned existing)
}
// RecordAgreement records a user's agreement to an EULA version.
// If the user has already agreed to this version, returns the existing agreement
// with AlreadyAgreed=true (idempotent operation).
//
// Parameters:
// - ctx: Context for cancellation and timeout
// - input: RecordAgreementInput with user and agreement details
//
// Returns:
// - RecordAgreementResult with the agreement and whether it was pre-existing
// - An error if validation fails or the database operation fails
func (s *Service) RecordAgreement(ctx context.Context, input *RecordAgreementInput) (*RecordAgreementResult, error) {
if input.CognitoSubjectID == "" {
return nil, fmt.Errorf("cognitoSubjectID cannot be empty")
}
if input.UserEmail == "" {
return nil, fmt.Errorf("userEmail cannot be empty")
}
if input.EulaVersionID == uuid.Nil {
return nil, fmt.Errorf("eulaVersionID cannot be nil")
}
if input.IPAddress == "" {
return nil, fmt.Errorf("ipAddress cannot be empty")
}
// Check if already agreed (for idempotency)
existing, err := s.cfg.GetDBQueries().GetUserEulaAgreement(ctx, &repository.GetUserEulaAgreementParams{
Cognitosubjectid: input.CognitoSubjectID,
Eulaversionid: input.EulaVersionID,
})
if err == nil {
// User already agreed - return existing (idempotent)
return &RecordAgreementResult{
Agreement: existing,
AlreadyAgreed: true,
}, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("failed to check existing agreement: %w", err)
}
// Create new agreement
agreement, err := s.cfg.GetDBQueries().CreateEulaAgreement(ctx, &repository.CreateEulaAgreementParams{
Cognitosubjectid: input.CognitoSubjectID,
Useremail: input.UserEmail,
Eulaversionid: input.EulaVersionID,
Agreedfromip: input.IPAddress,
})
if err != nil {
return nil, fmt.Errorf("failed to create EULA agreement: %w", err)
}
return &RecordAgreementResult{
Agreement: agreement,
AlreadyAgreed: false,
}, nil
}
// HasUserAgreedToCurrent checks if a user has agreed to the current active EULA version.
//
// Parameters:
// - ctx: Context for cancellation and timeout
// - cognitoSubjectID: User's Cognito subject ID
//
// Returns:
// - true if the user has agreed to the current EULA, false otherwise
// - ErrNoCurrentVersion if no EULA is currently active
// - An error if the database query fails
func (s *Service) HasUserAgreedToCurrent(ctx context.Context, cognitoSubjectID string) (bool, error) {
_, err := s.cfg.GetDBQueries().GetUserCurrentEulaAgreement(ctx, cognitoSubjectID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("failed to check user's current EULA agreement: %w", err)
}
return true, nil
}
// UserEulaStatus contains the user's EULA agreement status information.
type UserEulaStatus struct {
HasAgreed bool
CurrentVersion string
CurrentVersionID uuid.UUID
AgreedAt *time.Time
AgreedVersion *string
}
// GetUserEulaStatus gets the full status information for UI display.
//
// Parameters:
// - ctx: Context for cancellation and timeout
// - cognitoSubjectID: User's Cognito subject ID
//
// Returns:
// - UserEulaStatus with full status information
// - ErrNoCurrentVersion if no EULA is currently active
// - An error if the database queries fail
func (s *Service) GetUserEulaStatus(ctx context.Context, cognitoSubjectID string) (*UserEulaStatus, error) {
// Get current version
currentVersion, err := s.GetCurrentVersion(ctx)
if err != nil {
return nil, err
}
status := &UserEulaStatus{
HasAgreed: false,
CurrentVersion: currentVersion.Version,
CurrentVersionID: currentVersion.ID,
}
// Check if user has agreed to current version
agreement, err := s.cfg.GetDBQueries().GetUserCurrentEulaAgreement(ctx, cognitoSubjectID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return status, nil
}
return nil, fmt.Errorf("failed to get user's current EULA agreement: %w", err)
}
status.HasAgreed = true
if agreement.Agreedat.Valid {
agreedAt := agreement.Agreedat.Time
status.AgreedAt = &agreedAt
}
status.AgreedVersion = &currentVersion.Version
return status, nil
}
// ListAgreementsInput contains pagination and filter parameters for listing agreements.
type ListAgreementsInput struct {
Page int32 // Page number (1-based)
PageSize int32 // Items per page
VersionID *uuid.UUID // Optional: filter by EULA version
UserID *string // Optional: filter by Cognito subject ID
}
// ListAgreementsResult contains the paginated list of agreements.
type ListAgreementsResult struct {
Agreements []*repository.ListEulaAgreementsRow
Total int64
HasMore bool
}
// ListAgreements retrieves a paginated list of EULA agreements with optional filters.
//
// Parameters:
// - ctx: Context for cancellation and timeout
// - input: Pagination and filter parameters
//
// Returns:
// - ListAgreementsResult with agreements and pagination metadata
// - An error if the database query fails
func (s *Service) ListAgreements(ctx context.Context, input *ListAgreementsInput) (*ListAgreementsResult, error) {
if input.Page < 1 {
input.Page = 1
}
if input.PageSize < 1 {
input.PageSize = 20
}
if input.PageSize > 100 {
input.PageSize = 100
}
offset := int64((input.Page - 1) * input.PageSize)
limit := int64(input.PageSize)
agreements, err := s.cfg.GetDBQueries().ListEulaAgreements(ctx, &repository.ListEulaAgreementsParams{
Limit: limit,
Offset: offset,
VersionID: input.VersionID,
UserID: input.UserID,
})
if err != nil {
return nil, fmt.Errorf("failed to list EULA agreements: %w", err)
}
total, err := s.cfg.GetDBQueries().CountEulaAgreements(ctx, &repository.CountEulaAgreementsParams{
VersionID: input.VersionID,
UserID: input.UserID,
})
if err != nil {
return nil, fmt.Errorf("failed to count EULA agreements: %w", err)
}
return &ListAgreementsResult{
Agreements: agreements,
Total: total,
HasMore: offset+int64(len(agreements)) < total,
}, nil
}
// GetUserAgreementHistory retrieves all EULA versions a specific user has agreed to.
//
// Parameters:
// - ctx: Context for cancellation and timeout
// - cognitoSubjectID: User's Cognito subject ID
//
// Returns:
// - A list of agreements with version details
// - An error if the database query fails
func (s *Service) GetUserAgreementHistory(ctx context.Context, cognitoSubjectID string) ([]*repository.ListEulaAgreementsByUserRow, error) {
history, err := s.cfg.GetDBQueries().ListEulaAgreementsByUser(ctx, cognitoSubjectID)
if err != nil {
return nil, fmt.Errorf("failed to get user agreement history: %w", err)
}
return history, nil
}
// ComplianceReportInput contains parameters for generating a compliance report.
type ComplianceReportInput struct {
VersionID *uuid.UUID // nil = use current version
Page int32 // Page number (1-based)
PageSize int32 // Items per page (max 100)
Agreed *bool // nil = all users, true = agreed only, false = not agreed only
}
// ComplianceReportUser represents a user in the compliance report.
type ComplianceReportUser struct {
CognitoSubjectID string // User's Cognito subject ID
Email *string // Email at time of agreement (nil if not agreed)
CurrentEmail string // Current email from Cognito
Agreed bool // Whether the user has agreed
AgreedAt *time.Time // When the user agreed (nil if not agreed)
AgreedFromIP *string // IP address from which user agreed (nil if not agreed)
}
// ComplianceReportSummary contains aggregate statistics.
type ComplianceReportSummary struct {
TotalUsers int32 // Total enabled users in Cognito
AgreedCount int32 // Users who have agreed to this version
NotAgreedCount int32 // Users who have not agreed
CompliancePercentage float32 // Percentage of users who have agreed (0-100)
}
// ComplianceReportResult contains the full compliance report.
type ComplianceReportResult struct {
Version *repository.Eulaversion // The EULA version being reported on
Summary ComplianceReportSummary // Aggregate statistics
Users []ComplianceReportUser // Paginated list of users
Page int32 // Current page number
PageSize int32 // Items per page
TotalPages int32 // Total number of pages
TotalItems int32 // Total number of items (after filtering)
}
// ApplyCompliancePaginationDefaults sets default pagination values for compliance report input.
func (s *Service) ApplyCompliancePaginationDefaults(input *ComplianceReportInput) {
if input.Page < 1 {
input.Page = 1
}
if input.PageSize < 1 {
input.PageSize = 50
}
if input.PageSize > 100 {
input.PageSize = 100
}
}
// ResolveComplianceVersion resolves the target EULA version for compliance reporting.
// If versionID is provided, fetches that version; otherwise fetches the current version.
func (s *Service) ResolveComplianceVersion(ctx context.Context, versionID *uuid.UUID) (*repository.Eulaversion, error) {
if versionID != nil {
return s.GetVersionByID(ctx, *versionID)
}
return s.GetCurrentVersion(ctx)
}
// BuildComplianceUserList builds the compliance user list from Cognito users and agreements.
// Returns the user list and summary statistics.
func (s *Service) BuildComplianceUserList(
cognitoUsers []usermanagement.CognitoUserResponse,
agreements []*repository.Eulaagreement,
) ([]ComplianceReportUser, ComplianceReportSummary) {
// Build agreement lookup map
agreementMap := make(map[string]*repository.Eulaagreement, len(agreements))
for _, agreement := range agreements {
agreementMap[agreement.Cognitosubjectid] = agreement
}
// Build user list
allUsers := make([]ComplianceReportUser, 0, len(cognitoUsers))
var agreedCount int32
for _, cognitoUser := range cognitoUsers {
user := s.BuildComplianceUser(cognitoUser, agreementMap)
if user.Agreed {
agreedCount++
}
allUsers = append(allUsers, user)
}
// Calculate summary with safe int conversion
totalUsers := SafeIntToInt32(len(cognitoUsers))
notAgreedCount := totalUsers - agreedCount
var compliancePercentage float32
if totalUsers > 0 {
compliancePercentage = float32(agreedCount) / float32(totalUsers) * 100
}
summary := ComplianceReportSummary{
TotalUsers: totalUsers,
AgreedCount: agreedCount,
NotAgreedCount: notAgreedCount,
CompliancePercentage: compliancePercentage,
}
return allUsers, summary
}
// BuildComplianceUser builds a single compliance user record from Cognito user and agreement map.
func (s *Service) BuildComplianceUser(
cognitoUser usermanagement.CognitoUserResponse,
agreementMap map[string]*repository.Eulaagreement,
) ComplianceReportUser {
user := ComplianceReportUser{
CognitoSubjectID: cognitoUser.SubjectID,
CurrentEmail: cognitoUser.Email,
Agreed: false,
}
if agreement, found := agreementMap[cognitoUser.SubjectID]; found {
user.Agreed = true
user.Email = &agreement.Useremail
if agreement.Agreedat.Valid {
agreedAt := agreement.Agreedat.Time
user.AgreedAt = &agreedAt
}
user.AgreedFromIP = &agreement.Agreedfromip
}
return user
}
// FilterAndSortComplianceUsers filters users by agreement status and sorts them.
// Agreed users come first (sorted by agreedAt DESC), then not-agreed (sorted by email ASC).
func (s *Service) FilterAndSortComplianceUsers(users []ComplianceReportUser, agreedFilter *bool) []ComplianceReportUser {
// Apply filter if specified
filtered := users
if agreedFilter != nil {
filtered = make([]ComplianceReportUser, 0)
for _, user := range users {
if user.Agreed == *agreedFilter {
filtered = append(filtered, user)
}
}
}
// Sort: agreed first (by agreedAt DESC), then not-agreed (by email ASC)
sort.Slice(filtered, func(i, j int) bool {
return CompareComplianceUsers(filtered[i], filtered[j])
})
return filtered
}
// CompareComplianceUsers compares two compliance users for sorting.
// Returns true if user i should come before user j.
func CompareComplianceUsers(i, j ComplianceReportUser) bool {
// Agreed users come first
if i.Agreed != j.Agreed {
return i.Agreed
}
// Among agreed users, sort by agreedAt DESC (most recent first)
if i.Agreed && j.Agreed {
if i.AgreedAt != nil && j.AgreedAt != nil {
return i.AgreedAt.After(*j.AgreedAt)
}
return i.AgreedAt != nil
}
// Among not-agreed users, sort by email ASC
return i.CurrentEmail < j.CurrentEmail
}
// PaginateComplianceUsers applies pagination to the filtered user list.
// Returns the paginated slice, total items count, and total pages count.
func (s *Service) PaginateComplianceUsers(users []ComplianceReportUser, page, pageSize int32) ([]ComplianceReportUser, int32, int32) {
totalItems := SafeIntToInt32(len(users))
totalPages := (totalItems + pageSize - 1) / pageSize
if totalPages < 1 {
totalPages = 1
}
offset := (page - 1) * pageSize
end := offset + pageSize
// Handle bounds
if offset > totalItems {
offset = totalItems
}
if end > totalItems {
end = totalItems
}
return users[offset:end], totalItems, totalPages
}
// SafeIntToInt32 safely converts an int to int32, capping at math.MaxInt32 if needed.
func SafeIntToInt32(n int) int32 {
if n > math.MaxInt32 {
return math.MaxInt32
}
if n < 0 {
return 0
}
return int32(n) // #nosec G115 -- bounds checked above
}
// FetchAllAgreementsForVersion fetches all agreements for a specific EULA version.
// Uses pagination to handle large numbers of agreements.
//
// Parameters:
// - ctx: Context for cancellation and timeout
// - versionID: The EULA version UUID
//
// Returns:
// - A slice of all agreements for this version
// - An error if the database query fails
func (s *Service) FetchAllAgreementsForVersion(
ctx context.Context,
versionID uuid.UUID,
) ([]*repository.Eulaagreement, error) {
const batchSize = 1000
var allAgreements []*repository.Eulaagreement
var offset int64
for {
agreements, err := s.cfg.GetDBQueries().ListAgreedUsersForVersion(ctx, &repository.ListAgreedUsersForVersionParams{
Eulaversionid: versionID,
Limit: batchSize,
Offset: offset,
})
if err != nil {
return nil, err
}
allAgreements = append(allAgreements, agreements...)
// Check if there are more records
if len(agreements) < batchSize {
break
}
offset += batchSize
}
return allAgreements, nil
}
+121
View File
@@ -0,0 +1,121 @@
// Package eula provides EULA (End User License Agreement) version management and compliance tracking.
// This file contains functions that require Cognito integration and cannot be tested with localstack.
package eula
import (
"context"
"fmt"
"queryorchestration/internal/usermanagement"
"github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider"
)
// GetComplianceReport generates a compliance report showing which users have agreed to an EULA version.
// This function requires Cognito access to list users and compare against recorded agreements.
//
// The report includes:
// - Version information for the target EULA
// - Summary statistics (total users, agreed count, not agreed count, compliance percentage)
// - Paginated list of users with their agreement status
//
// Parameters:
// - ctx: Context for cancellation and timeout
// - cognitoClient: AWS Cognito Identity Provider client
// - userPoolID: The Cognito User Pool ID to query for users
// - input: Report configuration including optional version_id, pagination, and filters
//
// Returns:
// - ComplianceReportResult with version info, summary stats, and paginated user list
// - ErrNoCurrentVersion if no version_id provided and no current version exists
// - ErrVersionNotFound if the specified version_id does not exist
// - An error if Cognito or database queries fail
func (s *Service) GetComplianceReport(
ctx context.Context,
cognitoClient *cognitoidentityprovider.Client,
userPoolID string,
input *ComplianceReportInput,
) (*ComplianceReportResult, error) {
// Apply default pagination values
s.ApplyCompliancePaginationDefaults(input)
// Step 1: Resolve target EULA version
version, err := s.ResolveComplianceVersion(ctx, input.VersionID)
if err != nil {
return nil, err
}
// Step 2: Fetch all enabled users from Cognito
cognitoUsers, err := s.fetchAllEnabledCognitoUsers(ctx, cognitoClient, userPoolID)
if err != nil {
return nil, fmt.Errorf("failed to fetch Cognito users: %w", err)
}
// Step 3: Fetch all agreements for this version from DB
agreements, err := s.FetchAllAgreementsForVersion(ctx, version.ID)
if err != nil {
return nil, fmt.Errorf("failed to fetch agreements: %w", err)
}
// Step 4: Build user list with agreement status and compute summary
allUsers, summary := s.BuildComplianceUserList(cognitoUsers, agreements)
// Step 5: Apply filters and sort
filteredUsers := s.FilterAndSortComplianceUsers(allUsers, input.Agreed)
// Step 6: Apply pagination
paginatedUsers, totalItems, totalPages := s.PaginateComplianceUsers(filteredUsers, input.Page, input.PageSize)
return &ComplianceReportResult{
Version: version,
Summary: summary,
Users: paginatedUsers,
Page: input.Page,
PageSize: input.PageSize,
TotalPages: totalPages,
TotalItems: totalItems,
}, nil
}
// fetchAllEnabledCognitoUsers fetches all enabled users from Cognito using pagination.
// Cognito has a max limit of 60 users per request, so this loops until all users are fetched.
//
// Parameters:
// - ctx: Context for cancellation and timeout
// - client: AWS Cognito Identity Provider client
// - userPoolID: The Cognito User Pool ID
//
// Returns:
// - A slice of all enabled Cognito users
// - An error if any Cognito API call fails
func (s *Service) fetchAllEnabledCognitoUsers(
ctx context.Context,
client *cognitoidentityprovider.Client,
userPoolID string,
) ([]usermanagement.CognitoUserResponse, error) {
const maxPerRequest = 60
var allUsers []usermanagement.CognitoUserResponse
var paginationToken *string
for {
result, err := usermanagement.ListCognitoUsers(ctx, client, userPoolID, maxPerRequest, paginationToken)
if err != nil {
return nil, err
}
// Filter to only enabled users
for _, user := range result.Users {
if user.Enabled {
allUsers = append(allUsers, user)
}
}
// Check if there are more pages
if result.PaginationKey == nil || *result.PaginationKey == "" {
break
}
paginationToken = result.PaginationKey
}
return allUsers, nil
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -320,6 +320,10 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
cognitoauth.RegisterRoutes(cfg.GetRouter(), cfg)
// auth end
// Test user injection middleware - only active when DISABLE_AUTH=true
// This allows testing user-authenticated endpoints by passing X-Test-User-Subject header
e.Use(cognitoauth.TestUserMiddleware())
opnapi, err := cfg.RegisterHandlers()
if err != nil {
return nil, err