Skip to content

Commit

Permalink
Basic Audit Log DB Impl + Tests
Browse files Browse the repository at this point in the history
  • Loading branch information
gbdubs committed Sep 3, 2023
1 parent f9ff751 commit 1efc7c6
Show file tree
Hide file tree
Showing 16 changed files with 853 additions and 64 deletions.
5 changes: 4 additions & 1 deletion db/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library")

go_library(
name = "db",
srcs = ["db.go"],
srcs = [
"db.go",
"queries.go",
],
importpath = "github.com/RMI/pacta/db",
visibility = ["//visibility:public"],
deps = ["//pacta"],
Expand Down
54 changes: 54 additions & 0 deletions db/queries.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package db

import (
"time"

"github.com/RMI/pacta/pacta"
)

type Cursor string

type PageInfo struct {
Cursor Cursor
HasNextPage bool
}

type AuditLogQuerySortBy string

const (
AuditLogQuerySortBy_CreatedAt AuditLogQuerySortBy = "created_at"
AuditLogQuerySortBy_ActorType AuditLogQuerySortBy = "actor_type"
AuditLogQuerySortBy_ActorID AuditLogQuerySortBy = "actor_id"
AuditLogQuerySortBy_ActorOwnerID AuditLogQuerySortBy = "actor_owner_id"
AuditLogQuerySortBy_PrimaryTargetID AuditLogQuerySortBy = "primary_target_id"
AuditLogQuerySortBy_PrimaryTargetType AuditLogQuerySortBy = "primary_target_type"
AuditLogQuerySortBy_PrimaryTargetOwnerID AuditLogQuerySortBy = "primary_target_owner_id"
AuditLogQuerySortBy_SecondaryTargetID AuditLogQuerySortBy = "secondary_target_id"
AuditLogQuerySortBy_SecondaryTargetType AuditLogQuerySortBy = "secondary_target_type"
AuditLogQuerySortBy_SecondaryTargetOwnerID AuditLogQuerySortBy = "secondary_target_owner_id"
)

type AuditLogQuerySort struct {
By AuditLogQuerySortBy
Ascending bool
}

type AuditLogQueryWhere struct {
InID []pacta.AuditLogID
MinCreatedAt time.Time
MaxCreatedAt time.Time
InActionType []pacta.AuditLogAction
InActorType []pacta.AuditLogActorType
InActorID []string
InActorOwnerID []pacta.OwnerID
InTargetType []pacta.AuditLogTargetType
InTargetID []string
InTargetOwnerID []pacta.OwnerID
}

type AuditLogQuery struct {
Cursor Cursor
Limit int
Wheres []*AuditLogQueryWhere
Sorts []*AuditLogQuerySort
}
3 changes: 3 additions & 0 deletions db/sqldb/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "sqldb",
srcs = [
"audit_log.go",
"blob.go",
"cursor.go",
"initiative.go",
"initiative_invitation.go",
"initiative_user.go",
Expand All @@ -30,6 +32,7 @@ go_test(
name = "sqldb_test",
size = "large",
srcs = [
"audit_log_test.go",
"blob_test.go",
"initiative_invitation_test.go",
"initiative_test.go",
Expand Down
260 changes: 260 additions & 0 deletions db/sqldb/audit_log.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
package sqldb

import (
"errors"
"fmt"
"strings"

"github.com/RMI/pacta/db"
"github.com/RMI/pacta/pacta"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)

const auditLogIDNamespace = "al"
const auditLogSelectColumns = `
audit_log.id,
audit_log.action,
audit_log.actor_type,
audit_log.actor_id,
audit_log.actor_owner_id,
audit_log.primary_target_type,
audit_log.primary_target_id,
audit_log.primary_target_owner_id,
audit_log.secondary_target_type,
audit_log.secondary_target_id,
audit_log.secondary_target_owner_id,
audit_log.created_at
`

func (d *DB) AuditLogs(tx db.Tx, q *db.AuditLogQuery) ([]*pacta.AuditLog, *db.PageInfo, error) {
if q.Limit <= 0 {
return nil, nil, fmt.Errorf("limit must be greater than 0, was %d", q.Limit)
}
offset, err := offsetFromCursor(q.Cursor)
if err != nil {
return nil, nil, fmt.Errorf("converting cursor to offset: %w", err)
}
sql, args, err := auditLogQuery(q)
if err != nil {
return nil, nil, fmt.Errorf("building audit_log query: %w", err)
}
rows, err := d.query(tx, sql, args...)
if err != nil {
return nil, nil, fmt.Errorf("executing audit_log query: %w", err)
}
als, err := rowsToAuditLogs(rows)
if err != nil {
return nil, nil, fmt.Errorf("getting audit_logs from rows: %w", err)
}
// This will incorrectly say "yes there are more results" if we happen to hit the actual limit, but
// that's a pretty small performance loss.
hasNextPage := len(als) == q.Limit
cursor := offsetToCursor(offset + len(als))
return als, &db.PageInfo{HasNextPage: hasNextPage, Cursor: db.Cursor(cursor)}, nil
}

func (d *DB) CreateAuditLog(tx db.Tx, a *pacta.AuditLog) (pacta.AuditLogID, error) {
if err := validateAuditLogForCreation(a); err != nil {
return "", fmt.Errorf("validating audit_log for creation: %w", err)
}
id := pacta.AuditLogID(d.randomID(auditLogIDNamespace))
ownerFn := func(o *pacta.Owner) any {
if o == nil {
return pgtype.Text{}
}
return o.ID
}
var stt *string
if a.SecondaryTargetType != "" {
s := string(a.SecondaryTargetType)
stt = &s
}
err := d.exec(tx, `
INSERT INTO audit_log
(
id, action, actor_type, actor_id, actor_owner_id,
primary_target_type, primary_target_id, primary_target_owner_id,
secondary_target_type, secondary_target_id, secondary_target_owner_id
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
`, id, a.Action, a.ActorType, a.ActorID, ownerFn(a.ActorOwner),
a.PrimaryTargetType, a.PrimaryTargetID, ownerFn(a.PrimaryTargetOwner),
stt, a.SecondaryTargetID, ownerFn(a.SecondaryTargetOwner))
if err != nil {
return "", fmt.Errorf("creating audit_log row: %w", err)
}
return id, nil
}

func rowsToAuditLogs(rows pgx.Rows) ([]*pacta.AuditLog, error) {
return allRows("auditLog", rows, rowToAuditLog)
}

func rowToAuditLog(row rowScanner) (*pacta.AuditLog, error) {
a := &pacta.AuditLog{}
var actorType, primaryType string
var actorOwner, primaryOwner pacta.OwnerID
var secondaryType, secondaryOwner pgtype.Text
err := row.Scan(
&a.ID, &a.Action, &actorType, &a.ActorID, &actorOwner,
&primaryType, &a.PrimaryTargetID, &primaryOwner,
&secondaryType, &a.SecondaryTargetID, &secondaryOwner,
&a.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("scanning into audit_log: %w", err)
}
if t, err := pacta.ParseAuditLogActorType(actorType); err != nil {
return nil, fmt.Errorf("parsing audit_log actor_type: %w", err)
} else {
a.ActorType = t
}
if t, err := pacta.ParseAuditLogTargetType(primaryType); err != nil {
return nil, fmt.Errorf("parsing audit_log primary_target_type: %w", err)
} else {
a.PrimaryTargetType = t
}
if secondaryType.Valid {
if t, err := pacta.ParseAuditLogTargetType(secondaryType.String); err != nil {
return nil, fmt.Errorf("parsing audit_log secondary_target_type: %w", err)
} else {
a.SecondaryTargetType = t
}
}
if secondaryOwner.Valid {
a.SecondaryTargetOwner = &pacta.Owner{ID: pacta.OwnerID(secondaryOwner.String)}
}
if actorOwner != "" {
a.ActorOwner = &pacta.Owner{ID: actorOwner}
}
if primaryOwner != "" {
a.PrimaryTargetOwner = &pacta.Owner{ID: primaryOwner}
}
return a, nil
}

func validateAuditLogForCreation(a *pacta.AuditLog) error {
if a.ID != "" {
return fmt.Errorf("audit log already has an ID")
}
if !a.CreatedAt.IsZero() {
return fmt.Errorf("audit log already has a CreatedAt")
}
if a.Action == "" {
return fmt.Errorf("audit log missing required action")
}
if a.ActorType == "" {
return fmt.Errorf("audit log missing ActorType")
}
if a.ActorID == "" {
return fmt.Errorf("audit log missing ActorID")
}
if a.ActorOwner == nil {
return fmt.Errorf("audit log ActorOwner is nil")
}
if a.ActorOwner.ID == "" {
return fmt.Errorf("audit log ActorOwnerID is empty")
}
if a.PrimaryTargetType == "" {
return fmt.Errorf("audit log missing PrimaryTargetType")
}
if a.PrimaryTargetID == "" {
return fmt.Errorf("audit log missing PrimaryTargetID")
}
if a.PrimaryTargetOwner == nil {
return fmt.Errorf("audit log PrimaryTargetOwner is nil")
}
if a.PrimaryTargetOwner.ID == "" {
return fmt.Errorf("audit log PrimaryTargetOwnerID is empty")
}
return nil
}

func auditLogQuery(q *db.AuditLogQuery) (string, []any, error) {
args := &queryArgs{}
selectFrom := `SELECT ` + auditLogSelectColumns + ` FROM audit_log `
where := auditLogQueryWheresToSQL(q.Wheres, args)
if where == "" {
return "", nil, errors.New("where clause cannot be empty in audit_log query")
}
order := auditLogQuerySortsToSQL(q.Sorts)
limit := fmt.Sprintf("LIMIT %d", q.Limit)
offset := ""
if q.Cursor != "" {
o, err := offsetFromCursor(q.Cursor)
if err != nil {
return "", nil, fmt.Errorf("extracting offset from cursor in audit-log query: %w", err)
}
offset = fmt.Sprintf("OFFSET %d", o)
}
sql := fmt.Sprintf("%s %s %s %s %s;", selectFrom, where, order, limit, offset)
return sql, args.values, nil
}

func auditLogQuerySortsToSQL(ss []*db.AuditLogQuerySort) string {
sorts := []string{}
for _, s := range ss {
v := " DESC"
if s.Ascending {
v = " ASC"
}
sorts = append(sorts, fmt.Sprintf("audit_log.%s %s", s.By, v))
}
// Forces a deterministic sort for pagination.
sorts = append(sorts, "audit_log.id ASC")
return "ORDER BY " + strings.Join(sorts, ", ")
}

func auditLogQueryWheresToSQL(qs []*db.AuditLogQueryWhere, args *queryArgs) string {
wheres := []string{}
for _, q := range qs {
if len(q.InID) > 0 {
wheres = append(wheres, eqOrIn("audit_log.id", q.InID, args))
}
if len(q.InActionType) > 0 {
wheres = append(wheres, eqOrIn("audit_log.action", q.InActionType, args))
}
if !q.MinCreatedAt.IsZero() {
wheres = append(wheres, "audit_log.created_at >= "+args.add(q.MinCreatedAt))
}
if !q.MaxCreatedAt.IsZero() {
wheres = append(wheres, "audit_log.created_at <= "+args.add(q.MaxCreatedAt))
}
if len(q.InActorType) > 0 {
wheres = append(wheres, eqOrIn("audit_log.actor_type", q.InActorType, args))
}
if len(q.InActorID) > 0 {
wheres = append(wheres, eqOrIn("audit_log.actor_id", q.InActorID, args))
}
if len(q.InActorOwnerID) > 0 {
wheres = append(wheres, eqOrIn("audit_log.actor_owner_id", q.InActorOwnerID, args))
}
if len(q.InTargetType) > 0 {
or := fmt.Sprintf("(%s OR %s)",
eqOrIn("audit_log.primary_target_type", q.InTargetType, args),
eqOrIn("audit_log.secondary_target_type", q.InTargetType, args),
)
wheres = append(wheres, or)
}
if len(q.InTargetID) > 0 {
or := fmt.Sprintf("(%s OR %s)",
eqOrIn("audit_log.primary_target_id", q.InTargetID, args),
eqOrIn("audit_log.secondary_target_id", q.InTargetID, args),
)
wheres = append(wheres, or)
}
if len(q.InTargetOwnerID) > 0 {
or := fmt.Sprintf("(%s OR %s)",
eqOrIn("audit_log.primary_target_owner_id", q.InTargetOwnerID, args),
eqOrIn("audit_log.secondary_target_owner_id", q.InTargetOwnerID, args),
)
wheres = append(wheres, or)
}
}
if len(wheres) == 0 {
return ""
}
return "WHERE " + strings.Join(wheres, " AND ")
}
Loading

0 comments on commit 1efc7c6

Please sign in to comment.