Add log export for audit logs and SCIM events

Route audit-log and SCIM-event exports through export_jobs with typed
arguments, an iam BuildAndUploadExport/SendExportEmail implementation,
and a concurrent export-job worker with stale recovery. Stream JSONL via
page.WalkAll into S3, and expose the request flow on console, connect,
MCP, and CLI.

Co-authored-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
This commit is contained in:
Sacha Al Himdani
2026-07-29 16:12:23 +02:00
parent b2e2d15582
commit cd6c46212a
45 changed files with 2340 additions and 153 deletions

View File

@@ -0,0 +1,164 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package probo
import (
"context"
"errors"
"fmt"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.gearno.de/kit/worker"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
const defaultExportJobStaleAfter = 25 * time.Minute
type (
exportJobHandler struct {
service *Service
logger *log.Logger
staleAfter time.Duration
}
ExportJobWorkerConfig struct {
StaleAfter time.Duration
}
)
var (
_ worker.Handler[coredata.ExportJob] = (*exportJobHandler)(nil)
_ worker.StaleRecoverer = (*exportJobHandler)(nil)
)
func NewExportJobWorker(
service *Service,
logger *log.Logger,
cfg ExportJobWorkerConfig,
opts ...worker.Option,
) *worker.Worker[coredata.ExportJob] {
staleAfter := cfg.StaleAfter
if staleAfter <= 0 {
staleAfter = defaultExportJobStaleAfter
}
h := &exportJobHandler{
service: service,
logger: logger,
staleAfter: staleAfter,
}
return worker.New(
"export-job-worker",
h,
logger,
opts...,
)
}
func (h *exportJobHandler) Claim(ctx context.Context) (coredata.ExportJob, error) {
exportJob, err := h.service.lockExportJob(ctx)
if err != nil {
if errors.Is(err, coredata.ErrNoExportJobAvailable) {
return coredata.ExportJob{}, worker.ErrNoTask
}
return coredata.ExportJob{}, err
}
return *exportJob, nil
}
func (h *exportJobHandler) Process(ctx context.Context, exportJob coredata.ExportJob) error {
stopHeartbeat := h.startHeartbeat(ctx, exportJob.ID)
defer stopHeartbeat()
if err := h.service.processExportJob(ctx, &exportJob); err != nil {
h.logger.ErrorCtx(
ctx,
"export job worker failure",
log.Error(err),
log.String("export_job_id", exportJob.ID.String()),
log.String("export_job_type", exportJob.Type.String()),
)
return err
}
return nil
}
func (h *exportJobHandler) RecoverStale(ctx context.Context) error {
return h.service.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := coredata.ResetStaleExportJobs(ctx, conn, h.staleAfter); err != nil {
return fmt.Errorf("cannot reset stale export jobs: %w", err)
}
return nil
},
)
}
func (h *exportJobHandler) startHeartbeat(ctx context.Context, exportJobID gid.GID) func() {
done := make(chan struct{})
interval := max(h.staleAfter/2, time.Second)
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ctx.Done():
return
case <-ticker.C:
if err := h.service.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return coredata.TouchExportJobLease(
ctx,
conn,
coredata.NewScope(exportJobID.TenantID()),
exportJobID,
)
},
); err != nil {
h.logger.ErrorCtx(
ctx,
"cannot renew export job lease",
log.Error(err),
log.String("export_job_id", exportJobID.String()),
)
}
}
}
}()
return func() { close(done) }
}

View File

@@ -118,6 +118,7 @@ type (
GeneratedDocuments *GeneratedDocumentService
Files *FileService
SlackMessages *slack.Service
LogExports ExportService
}
)
@@ -231,16 +232,12 @@ func NewService(
svc.GeneratedDocuments = &GeneratedDocumentService{svc: svc}
svc.Files = &FileService{svc: svc}
svc.SlackMessages = slackService
svc.LogExports = iamService.LogExports
return svc, nil
}
func (s *Service) ExportJob(ctx context.Context) error {
exportJob, err := s.lockExportJob(ctx)
if err != nil {
return fmt.Errorf("cannot lock export job: %w", err)
}
func (s *Service) processExportJob(ctx context.Context, exportJob *coredata.ExportJob) error {
scope := coredata.NewScope(exportJob.ID.TenantID())
var exportService ExportService
@@ -250,6 +247,8 @@ func (s *Service) ExportJob(ctx context.Context) error {
exportService = s.Frameworks
case coredata.ExportJobTypeDocument:
exportService = s.Documents
case coredata.ExportJobTypeAuditLog, coredata.ExportJobTypeSCIMEvent:
exportService = s.LogExports
default:
unknownTypeErr := fmt.Errorf("unknown export job type: %q", exportJob.Type)
if err := s.commitFailedExport(ctx, exportJob, unknownTypeErr); err != nil {
@@ -314,10 +313,7 @@ func (s *Service) lockExportJob(ctx context.Context) (*coredata.ExportJob, error
scope = coredata.NewScope(exportJob.ID.TenantID())
exportJob.Status = coredata.ExportJobStatusProcessing
exportJob.StartedAt = new(time.Now())
if err := exportJob.Update(ctx, tx, scope); err != nil {
if err := exportJob.MarkProcessing(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update %s export job: %w", exportJob.Type, err)
}
@@ -341,7 +337,7 @@ func (s *Service) commitFailedExport(ctx context.Context, exportJob *coredata.Ex
ctx,
func(ctx context.Context, tx pg.Tx) error {
scope := coredata.NewScope(exportJob.ID.TenantID())
if err := exportJob.Update(ctx, tx, scope); err != nil {
if err := exportJob.UpdateIfStatus(ctx, tx, scope, coredata.ExportJobStatusProcessing); err != nil {
return fmt.Errorf("cannot update %s export job: %w", exportJob.Type, err)
}
@@ -358,7 +354,7 @@ func (s *Service) commitSuccessfulExport(ctx context.Context, exportJob *coredat
ctx,
func(ctx context.Context, tx pg.Tx) error {
scope := coredata.NewScope(exportJob.ID.TenantID())
if err := exportJob.Update(ctx, tx, scope); err != nil {
if err := exportJob.UpdateIfStatus(ctx, tx, scope, coredata.ExportJobStatusProcessing); err != nil {
return fmt.Errorf("cannot update %s export job: %w", exportJob.Type, err)
}