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

@@ -31,6 +31,7 @@ import (
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager"
"github.com/aws/aws-sdk-go-v2/service/s3"
smithyhttp "github.com/aws/smithy-go/transport/http"
"go.probo.inc/probo/pkg/coredata"
@@ -203,23 +204,51 @@ func (s *Service) OpenFile(
return obj, nil
}
type putFileConfig struct {
attachmentDisposition bool
}
// PutFileOption configures optional PutFile behavior.
type PutFileOption func(*putFileConfig)
// WithAttachmentContentDisposition stores Content-Disposition: attachment on the
// uploaded object so browsers download it when served from object storage.
func WithAttachmentContentDisposition() PutFileOption {
return func(cfg *putFileConfig) {
cfg.attachmentDisposition = true
}
}
func (s *Service) PutFile(
ctx context.Context,
file *coredata.File,
content io.Reader,
metadata map[string]string,
opts ...PutFileOption,
) (int64, error) {
_, err := s.s3Client.PutObject(
ctx,
&s3.PutObjectInput{
Bucket: new(file.BucketName),
Key: new(file.FileKey),
Body: content,
ContentType: new(file.MimeType),
CacheControl: new("private, max-age=3600"),
Metadata: metadata,
},
)
cfg := putFileConfig{}
for _, opt := range opts {
opt(&cfg)
}
// Transfer manager accepts unseekable readers (e.g. io.Pipe) by buffering
// parts in memory, which works against plain-HTTP S3-compatible endpoints
// where PutObject checksums require a seekable body.
uploader := transfermanager.New(s.s3Client)
input := &transfermanager.UploadObjectInput{
Bucket: new(file.BucketName),
Key: new(file.FileKey),
Body: content,
ContentType: new(file.MimeType),
CacheControl: new("private, max-age=3600"),
Metadata: metadata,
}
if cfg.attachmentDisposition && file.FileName != "" {
input.ContentDisposition = new(attachmentContentDisposition(file.FileName))
}
_, err := uploader.UploadObject(ctx, input)
if err != nil {
return 0, fmt.Errorf("cannot upload file to S3: %w", err)
}
@@ -245,11 +274,7 @@ func (s *Service) GeneratePresignedURL(
) (string, error) {
presignClient := s3.NewPresignClient(s.s3Client)
contentDisposition := fmt.Sprintf(
"attachment; filename=%q; filename*=UTF-8''%s",
asciiFilename(file.FileName),
url.PathEscape(file.FileName),
)
contentDisposition := attachmentContentDisposition(file.FileName)
presignedReq, err := presignClient.PresignGetObject(
ctx,
@@ -300,6 +325,14 @@ func ifRangeMatches(ifRange, etag string, lastModified time.Time) bool {
return !lastModified.IsZero() && lastModified.Truncate(time.Second).Equal(t)
}
func attachmentContentDisposition(filename string) string {
return fmt.Sprintf(
"attachment; filename=%q; filename*=UTF-8''%s",
asciiFilename(filename),
url.PathEscape(filename),
)
}
func asciiFilename(filename string) string {
var b strings.Builder
b.Grow(len(filename))