From 1f5cf99cfb162d1937238790a14db2b78b68e4e1 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Mon, 20 Apr 2026 22:29:59 +0000 Subject: [PATCH 1/2] feat(installations): add audit log --- internal/server/converter.go | 50 +- internal/server/server.go | 128 ++++- internal/server/server_test.go | 450 +++++++++++++++++- internal/server/validation.go | 7 + internal/server/validation_test.go | 36 ++ internal/store/audit_log.go | 221 +++++++++ internal/store/store.go | 25 +- .../0003_installation_status_audit_log.sql | 19 + 8 files changed, 901 insertions(+), 35 deletions(-) create mode 100644 internal/store/audit_log.go create mode 100644 migrations/0003_installation_status_audit_log.sql diff --git a/internal/server/converter.go b/internal/server/converter.go index 93c11c5..4e6bcf2 100644 --- a/internal/server/converter.go +++ b/internal/server/converter.go @@ -38,13 +38,18 @@ func toProtoInstallation(installation store.Installation) (*appsv1.Installation, if err != nil { return nil, err } - return &appsv1.Installation{ + protoInstallation := &appsv1.Installation{ Meta: toProtoEntityMeta(installation.Meta), AppId: installation.AppID.String(), OrganizationId: installation.OrganizationID.String(), Slug: installation.Slug, Configuration: configuration, - }, nil + } + if installation.Status != nil { + status := *installation.Status + protoInstallation.Status = &status + } + return protoInstallation, nil } func toProtoVisibility(visibility store.AppVisibility) appsv1.AppVisibility { @@ -69,6 +74,32 @@ func toStoreVisibility(visibility appsv1.AppVisibility) (store.AppVisibility, er } } +func toProtoAuditLogLevel(level store.InstallationAuditLogLevel) appsv1.InstallationAuditLogLevel { + switch level { + case store.InstallationAuditLogLevelInfo: + return appsv1.InstallationAuditLogLevel_INSTALLATION_AUDIT_LOG_LEVEL_INFO + case store.InstallationAuditLogLevelWarning: + return appsv1.InstallationAuditLogLevel_INSTALLATION_AUDIT_LOG_LEVEL_WARNING + case store.InstallationAuditLogLevelError: + return appsv1.InstallationAuditLogLevel_INSTALLATION_AUDIT_LOG_LEVEL_ERROR + default: + panic("unknown audit log level") + } +} + +func toStoreAuditLogLevel(level appsv1.InstallationAuditLogLevel) (store.InstallationAuditLogLevel, error) { + switch level { + case appsv1.InstallationAuditLogLevel_INSTALLATION_AUDIT_LOG_LEVEL_INFO: + return store.InstallationAuditLogLevelInfo, nil + case appsv1.InstallationAuditLogLevel_INSTALLATION_AUDIT_LOG_LEVEL_WARNING: + return store.InstallationAuditLogLevelWarning, nil + case appsv1.InstallationAuditLogLevel_INSTALLATION_AUDIT_LOG_LEVEL_ERROR: + return store.InstallationAuditLogLevelError, nil + default: + return "", fmt.Errorf("unknown audit log level %v", level) + } +} + func protoStructToMap(value *structpb.Struct) map[string]any { if value == nil { return map[string]any{} @@ -96,3 +127,18 @@ func toProtoAppProfile(app store.App) *appsv1.AppProfile { Icon: app.Icon, } } + +func toProtoInstallationAuditLogEntry(entry store.InstallationAuditLogEntry) *appsv1.InstallationAuditLogEntry { + protoEntry := &appsv1.InstallationAuditLogEntry{ + Id: entry.ID.String(), + InstallationId: entry.InstallationID.String(), + Message: entry.Message, + Level: toProtoAuditLogLevel(entry.Level), + CreatedAt: timestamppb.New(entry.CreatedAt), + } + if entry.IdempotencyKey != nil { + idempotencyKey := *entry.IdempotencyKey + protoEntry.IdempotencyKey = &idempotencyKey + } + return protoEntry +} diff --git a/internal/server/server.go b/internal/server/server.go index ec79d10..f750885 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "log" + "strings" appsv1 "github.com/agynio/apps/.gen/go/agynio/api/apps/v1" authorizationv1 "github.com/agynio/apps/.gen/go/agynio/api/authorization/v1" @@ -46,7 +47,10 @@ type AppStore interface { GetInstallationBySlug(ctx context.Context, organizationID uuid.UUID, slug string) (store.Installation, error) ListInstallations(ctx context.Context, pageSize int, pageToken string, filter store.ListInstallationsFilter) ([]store.Installation, string, error) UpdateInstallation(ctx context.Context, input store.UpdateInstallationInput) (store.Installation, error) + UpdateInstallationStatus(ctx context.Context, id uuid.UUID, status *string) (store.Installation, error) DeleteInstallation(ctx context.Context, id uuid.UUID) error + AppendInstallationAuditLogEntry(ctx context.Context, input store.AppendInstallationAuditLogEntryInput) (store.InstallationAuditLogEntry, error) + ListInstallationAuditLogEntries(ctx context.Context, installationID uuid.UUID, pageSize int, pageToken string) ([]store.InstallationAuditLogEntry, string, error) } type Server struct { @@ -627,22 +631,132 @@ func (s *Server) GetInstallationConfiguration(ctx context.Context, req *appsv1.G if err != nil { return nil, status.Errorf(codes.InvalidArgument, "id: %v", err) } - installation, err := s.store.GetInstallation(ctx, id) + installation, err := s.requireInstallationForAppIdentity(ctx, callerID, id) + if err != nil { + return nil, err + } + configuration, err := mapToProtoStruct(installation.Configuration) + if err != nil { + return nil, status.Errorf(codes.Internal, "convert configuration: %v", err) + } + return &appsv1.GetInstallationConfigurationResponse{Configuration: configuration}, nil +} + +func (s *Server) ReportInstallationStatus(ctx context.Context, req *appsv1.ReportInstallationStatusRequest) (*appsv1.ReportInstallationStatusResponse, error) { + callerID, err := identityFromMetadata(ctx) + if err != nil { + return nil, status.Errorf(codes.Unauthenticated, "unauthenticated: %v", err) + } + installationID, err := parseUUID(req.GetInstallationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "installation_id: %v", err) + } + installation, err := s.requireInstallationForAppIdentity(ctx, callerID, installationID) + if err != nil { + return nil, err + } + + statusValue := req.GetStatus() + var normalizedStatus *string + if strings.TrimSpace(statusValue) != "" { + normalizedStatus = &statusValue + } + + installation, err = s.store.UpdateInstallationStatus(ctx, installation.Meta.ID, normalizedStatus) if err != nil { return nil, toStatusError(err) } - app, err := s.store.GetApp(ctx, installation.AppID) + protoInstallation, err := toProtoInstallation(installation) + if err != nil { + return nil, status.Errorf(codes.Internal, "convert installation: %v", err) + } + return &appsv1.ReportInstallationStatusResponse{Installation: protoInstallation}, nil +} + +func (s *Server) AppendInstallationAuditLogEntry(ctx context.Context, req *appsv1.AppendInstallationAuditLogEntryRequest) (*appsv1.AppendInstallationAuditLogEntryResponse, error) { + callerID, err := identityFromMetadata(ctx) + if err != nil { + return nil, status.Errorf(codes.Unauthenticated, "unauthenticated: %v", err) + } + installationID, err := parseUUID(req.GetInstallationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "installation_id: %v", err) + } + if _, err := s.requireInstallationForAppIdentity(ctx, callerID, installationID); err != nil { + return nil, err + } + + message := req.GetMessage() + if err := validateAuditLogMessage(message); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "message: %v", err) + } + level, err := toStoreAuditLogLevel(req.GetLevel()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "level: %v", err) + } + var idempotencyKey *string + if trimmedKey := strings.TrimSpace(req.GetIdempotencyKey()); trimmedKey != "" { + idempotencyKey = &trimmedKey + } + + entry, err := s.store.AppendInstallationAuditLogEntry(ctx, store.AppendInstallationAuditLogEntryInput{ + InstallationID: installationID, + Message: message, + Level: level, + IdempotencyKey: idempotencyKey, + }) + if err != nil { + return nil, status.Errorf(codes.Internal, "append audit log entry: %v", err) + } + protoEntry := toProtoInstallationAuditLogEntry(entry) + return &appsv1.AppendInstallationAuditLogEntryResponse{Entry: protoEntry}, nil +} + +func (s *Server) ListInstallationAuditLogEntries(ctx context.Context, req *appsv1.ListInstallationAuditLogEntriesRequest) (*appsv1.ListInstallationAuditLogEntriesResponse, error) { + callerID, err := identityFromMetadata(ctx) + if err != nil { + return nil, status.Errorf(codes.Unauthenticated, "unauthenticated: %v", err) + } + installationID, err := parseUUID(req.GetInstallationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "installation_id: %v", err) + } + installation, err := s.store.GetInstallation(ctx, installationID) if err != nil { return nil, toStatusError(err) } - if app.IdentityID != callerID { - return nil, status.Error(codes.PermissionDenied, "permission denied") + if err := s.requireOrgMember(ctx, callerID, installation.OrganizationID); err != nil { + return nil, err } - configuration, err := mapToProtoStruct(installation.Configuration) + + entries, nextToken, err := s.store.ListInstallationAuditLogEntries(ctx, installationID, int(req.GetPageSize()), req.GetPageToken()) if err != nil { - return nil, status.Errorf(codes.Internal, "convert configuration: %v", err) + var invalidToken *store.InvalidPageTokenError + if errors.As(err, &invalidToken) { + return nil, status.Errorf(codes.InvalidArgument, "invalid page_token: %v", invalidToken.Err) + } + return nil, status.Errorf(codes.Internal, "list audit log entries: %v", err) } - return &appsv1.GetInstallationConfigurationResponse{Configuration: configuration}, nil + protoEntries := make([]*appsv1.InstallationAuditLogEntry, 0, len(entries)) + for _, entry := range entries { + protoEntries = append(protoEntries, toProtoInstallationAuditLogEntry(entry)) + } + return &appsv1.ListInstallationAuditLogEntriesResponse{Entries: protoEntries, NextPageToken: nextToken}, nil +} + +func (s *Server) requireInstallationForAppIdentity(ctx context.Context, identityID uuid.UUID, installationID uuid.UUID) (store.Installation, error) { + installation, err := s.store.GetInstallation(ctx, installationID) + if err != nil { + return store.Installation{}, toStatusError(err) + } + app, err := s.store.GetApp(ctx, installation.AppID) + if err != nil { + return store.Installation{}, toStatusError(err) + } + if app.IdentityID != identityID { + return store.Installation{}, status.Error(codes.PermissionDenied, "permission denied") + } + return installation, nil } func (s *Server) requireOrgOwner(ctx context.Context, identityID uuid.UUID, organizationID uuid.UUID) error { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 708f7cc..13e504a 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" "testing" "time" @@ -21,31 +22,37 @@ import ( ) type fakeStore struct { - createFn func(ctx context.Context, input storepkg.CreateAppInput) (storepkg.App, error) - updateFn func(ctx context.Context, input storepkg.UpdateAppInput) (storepkg.App, error) - getFn func(ctx context.Context, id uuid.UUID) (storepkg.App, error) - getBySlugFn func(ctx context.Context, organizationID uuid.UUID, slug string) (storepkg.App, error) - getByIdentityFn func(ctx context.Context, id uuid.UUID) (storepkg.App, error) - getByServiceTokenFn func(ctx context.Context, tokenHash string) (storepkg.App, error) - listFn func(ctx context.Context, pageSize int, pageToken string, filter storepkg.ListAppsFilter) ([]storepkg.App, string, error) - deleteFn func(ctx context.Context, id uuid.UUID) error - hasActiveInstallationsFn func(ctx context.Context, appID uuid.UUID) (bool, error) - updateZitiIdentityFn func(ctx context.Context, id uuid.UUID, zitiIdentityID string, zitiServiceID string) error - createInstallationFn func(ctx context.Context, input storepkg.CreateInstallationInput) (storepkg.Installation, error) - getInstallationFn func(ctx context.Context, id uuid.UUID) (storepkg.Installation, error) - getInstallationBySlugFn func(ctx context.Context, organizationID uuid.UUID, slug string) (storepkg.Installation, error) - listInstallationsFn func(ctx context.Context, pageSize int, pageToken string, filter storepkg.ListInstallationsFilter) ([]storepkg.Installation, string, error) - updateInstallationFn func(ctx context.Context, input storepkg.UpdateInstallationInput) (storepkg.Installation, error) - deleteInstallationFn func(ctx context.Context, id uuid.UUID) error - createInputs []storepkg.CreateAppInput - updateInputs []storepkg.UpdateAppInput - createInstallationInputs []storepkg.CreateInstallationInput - updateInstallationInputs []storepkg.UpdateInstallationInput - deleteInstallationCalls []uuid.UUID - deleteCalls []uuid.UUID - getCalls []uuid.UUID - getByServiceTokenCalls []string - updateZitiCalls []updateZitiCall + createFn func(ctx context.Context, input storepkg.CreateAppInput) (storepkg.App, error) + updateFn func(ctx context.Context, input storepkg.UpdateAppInput) (storepkg.App, error) + getFn func(ctx context.Context, id uuid.UUID) (storepkg.App, error) + getBySlugFn func(ctx context.Context, organizationID uuid.UUID, slug string) (storepkg.App, error) + getByIdentityFn func(ctx context.Context, id uuid.UUID) (storepkg.App, error) + getByServiceTokenFn func(ctx context.Context, tokenHash string) (storepkg.App, error) + listFn func(ctx context.Context, pageSize int, pageToken string, filter storepkg.ListAppsFilter) ([]storepkg.App, string, error) + deleteFn func(ctx context.Context, id uuid.UUID) error + hasActiveInstallationsFn func(ctx context.Context, appID uuid.UUID) (bool, error) + updateZitiIdentityFn func(ctx context.Context, id uuid.UUID, zitiIdentityID string, zitiServiceID string) error + createInstallationFn func(ctx context.Context, input storepkg.CreateInstallationInput) (storepkg.Installation, error) + getInstallationFn func(ctx context.Context, id uuid.UUID) (storepkg.Installation, error) + getInstallationBySlugFn func(ctx context.Context, organizationID uuid.UUID, slug string) (storepkg.Installation, error) + listInstallationsFn func(ctx context.Context, pageSize int, pageToken string, filter storepkg.ListInstallationsFilter) ([]storepkg.Installation, string, error) + updateInstallationFn func(ctx context.Context, input storepkg.UpdateInstallationInput) (storepkg.Installation, error) + updateInstallationStatusFn func(ctx context.Context, id uuid.UUID, status *string) (storepkg.Installation, error) + deleteInstallationFn func(ctx context.Context, id uuid.UUID) error + appendInstallationAuditLogEntryFn func(ctx context.Context, input storepkg.AppendInstallationAuditLogEntryInput) (storepkg.InstallationAuditLogEntry, error) + listInstallationAuditLogEntriesFn func(ctx context.Context, installationID uuid.UUID, pageSize int, pageToken string) ([]storepkg.InstallationAuditLogEntry, string, error) + createInputs []storepkg.CreateAppInput + updateInputs []storepkg.UpdateAppInput + createInstallationInputs []storepkg.CreateInstallationInput + updateInstallationInputs []storepkg.UpdateInstallationInput + updateInstallationStatusCalls []updateInstallationStatusCall + appendInstallationAuditLogInputs []storepkg.AppendInstallationAuditLogEntryInput + listInstallationAuditLogCalls []listAuditLogEntriesCall + deleteInstallationCalls []uuid.UUID + deleteCalls []uuid.UUID + getCalls []uuid.UUID + getByServiceTokenCalls []string + updateZitiCalls []updateZitiCall } type updateZitiCall struct { @@ -54,6 +61,90 @@ type updateZitiCall struct { zitiServiceID string } +type updateInstallationStatusCall struct { + id uuid.UUID + status *string +} + +type listAuditLogEntriesCall struct { + installationID uuid.UUID + pageSize int + pageToken string +} + +type auditLogStore struct { + entries []storepkg.InstallationAuditLogEntry + idempotency map[string]storepkg.InstallationAuditLogEntry + now time.Time +} + +func newAuditLogStore() *auditLogStore { + return &auditLogStore{ + entries: []storepkg.InstallationAuditLogEntry{}, + idempotency: map[string]storepkg.InstallationAuditLogEntry{}, + now: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), + } +} + +func (s *auditLogStore) append(input storepkg.AppendInstallationAuditLogEntryInput) storepkg.InstallationAuditLogEntry { + if input.IdempotencyKey != nil { + if existing, ok := s.idempotency[*input.IdempotencyKey]; ok { + return existing + } + } + entry := storepkg.InstallationAuditLogEntry{ + ID: uuid.New(), + InstallationID: input.InstallationID, + Message: input.Message, + Level: input.Level, + IdempotencyKey: input.IdempotencyKey, + CreatedAt: s.now, + } + s.now = s.now.Add(time.Minute) + s.entries = append(s.entries, entry) + if input.IdempotencyKey != nil { + s.idempotency[*input.IdempotencyKey] = entry + } + if len(s.entries) > 1000 { + removed := s.entries[0] + s.entries = s.entries[1:] + if removed.IdempotencyKey != nil { + delete(s.idempotency, *removed.IdempotencyKey) + } + } + return entry +} + +func (s *auditLogStore) list(pageSize int, pageToken string) ([]storepkg.InstallationAuditLogEntry, string, error) { + offset := 0 + if pageToken != "" { + parsed, err := strconv.Atoi(pageToken) + if err != nil { + return nil, "", err + } + offset = parsed + } + + ordered := make([]storepkg.InstallationAuditLogEntry, 0, len(s.entries)) + for i := len(s.entries) - 1; i >= 0; i-- { + ordered = append(ordered, s.entries[i]) + } + + if offset >= len(ordered) { + return []storepkg.InstallationAuditLogEntry{}, "", nil + } + end := offset + pageSize + if end > len(ordered) { + end = len(ordered) + } + entries := ordered[offset:end] + nextToken := "" + if end < len(ordered) { + nextToken = strconv.Itoa(end) + } + return entries, nextToken, nil +} + func (f *fakeStore) CreateApp(ctx context.Context, input storepkg.CreateAppInput) (storepkg.App, error) { f.createInputs = append(f.createInputs, input) if f.createFn != nil { @@ -171,6 +262,30 @@ func (f *fakeStore) UpdateInstallation(ctx context.Context, input storepkg.Updat return storepkg.Installation{}, errors.New("update installation not implemented") } +func (f *fakeStore) UpdateInstallationStatus(ctx context.Context, id uuid.UUID, status *string) (storepkg.Installation, error) { + f.updateInstallationStatusCalls = append(f.updateInstallationStatusCalls, updateInstallationStatusCall{id: id, status: status}) + if f.updateInstallationStatusFn != nil { + return f.updateInstallationStatusFn(ctx, id, status) + } + return storepkg.Installation{}, errors.New("update installation status not implemented") +} + +func (f *fakeStore) AppendInstallationAuditLogEntry(ctx context.Context, input storepkg.AppendInstallationAuditLogEntryInput) (storepkg.InstallationAuditLogEntry, error) { + f.appendInstallationAuditLogInputs = append(f.appendInstallationAuditLogInputs, input) + if f.appendInstallationAuditLogEntryFn != nil { + return f.appendInstallationAuditLogEntryFn(ctx, input) + } + return storepkg.InstallationAuditLogEntry{}, errors.New("append installation audit log entry not implemented") +} + +func (f *fakeStore) ListInstallationAuditLogEntries(ctx context.Context, installationID uuid.UUID, pageSize int, pageToken string) ([]storepkg.InstallationAuditLogEntry, string, error) { + f.listInstallationAuditLogCalls = append(f.listInstallationAuditLogCalls, listAuditLogEntriesCall{installationID: installationID, pageSize: pageSize, pageToken: pageToken}) + if f.listInstallationAuditLogEntriesFn != nil { + return f.listInstallationAuditLogEntriesFn(ctx, installationID, pageSize, pageToken) + } + return nil, "", errors.New("list installation audit log entries not implemented") +} + func (f *fakeStore) DeleteInstallation(ctx context.Context, id uuid.UUID) error { f.deleteInstallationCalls = append(f.deleteInstallationCalls, id) if f.deleteInstallationFn != nil { @@ -342,6 +457,10 @@ func newAdminContext() (context.Context, uuid.UUID) { return ctx, callerID } +func stringPtr(value string) *string { + return &value +} + func TestCreateAppSuccess(t *testing.T) { ctx, _ := newAdminContext() identityClient := &fakeIdentityClient{} @@ -1752,3 +1871,284 @@ func TestGetInstallationConfigurationRejectsNonAppIdentity(t *testing.T) { t.Fatalf("expected permission denied, got %v", status.Code(err)) } } + +func TestReportInstallationStatusClearsWhitespace(t *testing.T) { + identityID := uuid.New() + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs(identityMetadata, identityID.String())) + identityClient := &fakeIdentityClient{} + authorizationClient := &fakeAuthorizationClient{} + zitiClient := &fakeZitiManagementClient{} + store := &fakeStore{} + + installationID := uuid.New() + appID := uuid.New() + organizationID := uuid.New() + store.getInstallationFn = func(_ context.Context, _ uuid.UUID) (storepkg.Installation, error) { + return storepkg.Installation{ + Meta: storepkg.EntityMeta{ID: installationID}, + AppID: appID, + OrganizationID: organizationID, + }, nil + } + store.getFn = func(_ context.Context, _ uuid.UUID) (storepkg.App, error) { + return storepkg.App{Meta: storepkg.EntityMeta{ID: appID}, IdentityID: identityID}, nil + } + store.updateInstallationStatusFn = func(_ context.Context, id uuid.UUID, status *string) (storepkg.Installation, error) { + if id != installationID { + return storepkg.Installation{}, fmt.Errorf("unexpected installation id") + } + if status != nil { + return storepkg.Installation{}, fmt.Errorf("expected status cleared") + } + return storepkg.Installation{ + Meta: storepkg.EntityMeta{ID: installationID}, + AppID: appID, + OrganizationID: organizationID, + }, nil + } + + srv := New(store, identityClient, authorizationClient, zitiClient) + resp, err := srv.ReportInstallationStatus(ctx, &appsv1.ReportInstallationStatusRequest{ + InstallationId: installationID.String(), + Status: " ", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.GetInstallation().GetStatus() != "" { + t.Fatalf("expected cleared status") + } +} + +func TestReportInstallationStatusRejectsNonAppIdentity(t *testing.T) { + ctx, _ := newAdminContext() + identityClient := &fakeIdentityClient{} + authorizationClient := &fakeAuthorizationClient{} + zitiClient := &fakeZitiManagementClient{} + store := &fakeStore{} + + installationID := uuid.New() + appID := uuid.New() + store.getInstallationFn = func(_ context.Context, _ uuid.UUID) (storepkg.Installation, error) { + return storepkg.Installation{Meta: storepkg.EntityMeta{ID: installationID}, AppID: appID}, nil + } + store.getFn = func(_ context.Context, _ uuid.UUID) (storepkg.App, error) { + return storepkg.App{Meta: storepkg.EntityMeta{ID: appID}, IdentityID: uuid.New()}, nil + } + + srv := New(store, identityClient, authorizationClient, zitiClient) + _, err := srv.ReportInstallationStatus(ctx, &appsv1.ReportInstallationStatusRequest{ + InstallationId: installationID.String(), + Status: "ready", + }) + if status.Code(err) != codes.PermissionDenied { + t.Fatalf("expected permission denied, got %v", status.Code(err)) + } +} + +func TestAppendInstallationAuditLogEntryRejectsNonAppIdentity(t *testing.T) { + ctx, _ := newAdminContext() + identityClient := &fakeIdentityClient{} + authorizationClient := &fakeAuthorizationClient{} + zitiClient := &fakeZitiManagementClient{} + store := &fakeStore{} + + installationID := uuid.New() + appID := uuid.New() + store.getInstallationFn = func(_ context.Context, _ uuid.UUID) (storepkg.Installation, error) { + return storepkg.Installation{Meta: storepkg.EntityMeta{ID: installationID}, AppID: appID}, nil + } + store.getFn = func(_ context.Context, _ uuid.UUID) (storepkg.App, error) { + return storepkg.App{Meta: storepkg.EntityMeta{ID: appID}, IdentityID: uuid.New()}, nil + } + + srv := New(store, identityClient, authorizationClient, zitiClient) + _, err := srv.AppendInstallationAuditLogEntry(ctx, &appsv1.AppendInstallationAuditLogEntryRequest{ + InstallationId: installationID.String(), + Message: "failed", + Level: appsv1.InstallationAuditLogLevel_INSTALLATION_AUDIT_LOG_LEVEL_ERROR, + }) + if status.Code(err) != codes.PermissionDenied { + t.Fatalf("expected permission denied, got %v", status.Code(err)) + } +} + +func TestAppendInstallationAuditLogEntryIdempotency(t *testing.T) { + identityID := uuid.New() + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs(identityMetadata, identityID.String())) + identityClient := &fakeIdentityClient{} + authorizationClient := &fakeAuthorizationClient{} + zitiClient := &fakeZitiManagementClient{} + store := &fakeStore{} + + installationID := uuid.New() + appID := uuid.New() + auditLog := newAuditLogStore() + store.getInstallationFn = func(_ context.Context, _ uuid.UUID) (storepkg.Installation, error) { + return storepkg.Installation{Meta: storepkg.EntityMeta{ID: installationID}, AppID: appID}, nil + } + store.getFn = func(_ context.Context, _ uuid.UUID) (storepkg.App, error) { + return storepkg.App{Meta: storepkg.EntityMeta{ID: appID}, IdentityID: identityID}, nil + } + store.appendInstallationAuditLogEntryFn = func(_ context.Context, input storepkg.AppendInstallationAuditLogEntryInput) (storepkg.InstallationAuditLogEntry, error) { + return auditLog.append(input), nil + } + + srv := New(store, identityClient, authorizationClient, zitiClient) + first, err := srv.AppendInstallationAuditLogEntry(ctx, &appsv1.AppendInstallationAuditLogEntryRequest{ + InstallationId: installationID.String(), + Message: "attempt", + Level: appsv1.InstallationAuditLogLevel_INSTALLATION_AUDIT_LOG_LEVEL_INFO, + IdempotencyKey: stringPtr("same-key"), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + second, err := srv.AppendInstallationAuditLogEntry(ctx, &appsv1.AppendInstallationAuditLogEntryRequest{ + InstallationId: installationID.String(), + Message: "attempt", + Level: appsv1.InstallationAuditLogLevel_INSTALLATION_AUDIT_LOG_LEVEL_INFO, + IdempotencyKey: stringPtr("same-key"), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if first.GetEntry().GetId() != second.GetEntry().GetId() { + t.Fatalf("expected idempotent entry") + } + if len(auditLog.entries) != 1 { + t.Fatalf("expected one entry, got %d", len(auditLog.entries)) + } +} + +func TestAppendInstallationAuditLogEntryRetention(t *testing.T) { + identityID := uuid.New() + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs(identityMetadata, identityID.String())) + identityClient := &fakeIdentityClient{} + authorizationClient := &fakeAuthorizationClient{} + zitiClient := &fakeZitiManagementClient{} + store := &fakeStore{} + + installationID := uuid.New() + appID := uuid.New() + auditLog := newAuditLogStore() + store.getInstallationFn = func(_ context.Context, _ uuid.UUID) (storepkg.Installation, error) { + return storepkg.Installation{Meta: storepkg.EntityMeta{ID: installationID}, AppID: appID}, nil + } + store.getFn = func(_ context.Context, _ uuid.UUID) (storepkg.App, error) { + return storepkg.App{Meta: storepkg.EntityMeta{ID: appID}, IdentityID: identityID}, nil + } + store.appendInstallationAuditLogEntryFn = func(_ context.Context, input storepkg.AppendInstallationAuditLogEntryInput) (storepkg.InstallationAuditLogEntry, error) { + return auditLog.append(input), nil + } + + srv := New(store, identityClient, authorizationClient, zitiClient) + for i := 0; i < 1001; i++ { + _, err := srv.AppendInstallationAuditLogEntry(ctx, &appsv1.AppendInstallationAuditLogEntryRequest{ + InstallationId: installationID.String(), + Message: fmt.Sprintf("entry-%d", i), + Level: appsv1.InstallationAuditLogLevel_INSTALLATION_AUDIT_LOG_LEVEL_INFO, + IdempotencyKey: stringPtr(fmt.Sprintf("key-%d", i)), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + if len(auditLog.entries) != 1000 { + t.Fatalf("expected 1000 retained entries, got %d", len(auditLog.entries)) + } + if auditLog.entries[0].Message != "entry-1" { + t.Fatalf("expected oldest entry to be trimmed") + } +} + +func TestListInstallationAuditLogEntriesPagination(t *testing.T) { + ctx, _ := newAdminContext() + identityClient := &fakeIdentityClient{} + authorizationClient := &fakeAuthorizationClient{} + zitiClient := &fakeZitiManagementClient{} + store := &fakeStore{} + + installationID := uuid.New() + appID := uuid.New() + organizationID := uuid.New() + auditLog := newAuditLogStore() + store.getInstallationFn = func(_ context.Context, _ uuid.UUID) (storepkg.Installation, error) { + return storepkg.Installation{Meta: storepkg.EntityMeta{ID: installationID}, AppID: appID, OrganizationID: organizationID}, nil + } + store.listInstallationAuditLogEntriesFn = func(_ context.Context, _ uuid.UUID, pageSize int, pageToken string) ([]storepkg.InstallationAuditLogEntry, string, error) { + return auditLog.list(pageSize, pageToken) + } + + for i := 0; i < 4; i++ { + auditLog.append(storepkg.AppendInstallationAuditLogEntryInput{ + InstallationID: installationID, + Message: fmt.Sprintf("entry-%d", i), + Level: storepkg.InstallationAuditLogLevelInfo, + }) + } + + srv := New(store, identityClient, authorizationClient, zitiClient) + first, err := srv.ListInstallationAuditLogEntries(ctx, &appsv1.ListInstallationAuditLogEntriesRequest{ + InstallationId: installationID.String(), + PageSize: 2, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(first.GetEntries()) != 2 { + t.Fatalf("expected first page entries") + } + if first.GetEntries()[0].GetMessage() != "entry-3" || first.GetEntries()[1].GetMessage() != "entry-2" { + t.Fatalf("unexpected page order") + } + if first.GetNextPageToken() == "" { + t.Fatalf("expected next page token") + } + + second, err := srv.ListInstallationAuditLogEntries(ctx, &appsv1.ListInstallationAuditLogEntriesRequest{ + InstallationId: installationID.String(), + PageSize: 2, + PageToken: first.GetNextPageToken(), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(second.GetEntries()) != 2 { + t.Fatalf("expected second page entries") + } + if second.GetEntries()[0].GetMessage() != "entry-1" || second.GetEntries()[1].GetMessage() != "entry-0" { + t.Fatalf("unexpected second page order") + } + if second.GetNextPageToken() != "" { + t.Fatalf("expected no further page token") + } +} + +func TestListInstallationAuditLogEntriesRejectsNonMember(t *testing.T) { + ctx, callerID := newAdminContext() + identityClient := &fakeIdentityClient{} + authorizationClient := &fakeAuthorizationClient{} + zitiClient := &fakeZitiManagementClient{} + store := &fakeStore{} + + installationID := uuid.New() + organizationID := uuid.New() + store.getInstallationFn = func(_ context.Context, _ uuid.UUID) (storepkg.Installation, error) { + return storepkg.Installation{Meta: storepkg.EntityMeta{ID: installationID}, OrganizationID: organizationID}, nil + } + authorizationClient.checkFn = func(_ context.Context, req *authorizationv1.CheckRequest) (*authorizationv1.CheckResponse, error) { + if req.GetTupleKey().GetUser() != fmt.Sprintf("identity:%s", callerID.String()) { + return nil, errors.New("unexpected identity") + } + return &authorizationv1.CheckResponse{Allowed: false}, nil + } + + srv := New(store, identityClient, authorizationClient, zitiClient) + _, err := srv.ListInstallationAuditLogEntries(ctx, &appsv1.ListInstallationAuditLogEntriesRequest{ + InstallationId: installationID.String(), + }) + if status.Code(err) != codes.PermissionDenied { + t.Fatalf("expected permission denied, got %v", status.Code(err)) + } +} diff --git a/internal/server/validation.go b/internal/server/validation.go index e98929d..dee5133 100644 --- a/internal/server/validation.go +++ b/internal/server/validation.go @@ -30,6 +30,13 @@ func validateName(name string) error { return nil } +func validateAuditLogMessage(message string) error { + if strings.TrimSpace(message) == "" { + return fmt.Errorf("message must be provided") + } + return nil +} + func validatePermissions(permissions []string) error { seen := make(map[string]struct{}, len(permissions)) for _, permission := range permissions { diff --git a/internal/server/validation_test.go b/internal/server/validation_test.go index 0e5e466..472a7f2 100644 --- a/internal/server/validation_test.go +++ b/internal/server/validation_test.go @@ -119,3 +119,39 @@ func TestValidateName(t *testing.T) { }) } } + +func TestValidateAuditLogMessage(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + }{ + { + name: "valid", + value: "Installation started", + wantErr: false, + }, + { + name: "empty", + value: "", + wantErr: true, + }, + { + name: "whitespace", + value: " ", + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateAuditLogMessage(test.value) + if test.wantErr && err == nil { + t.Fatalf("expected error") + } + if !test.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} diff --git a/internal/store/audit_log.go b/internal/store/audit_log.go new file mode 100644 index 0000000..14c1474 --- /dev/null +++ b/internal/store/audit_log.go @@ -0,0 +1,221 @@ +package store + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +const ( + auditLogEntryColumns = `id, installation_id, message, level, idempotency_key, created_at` + auditLogRetentionLimit = 1000 + auditLogIdempotencyWindow = 24 * time.Hour +) + +type InstallationAuditLogLevel string + +const ( + InstallationAuditLogLevelInfo InstallationAuditLogLevel = "info" + InstallationAuditLogLevelWarning InstallationAuditLogLevel = "warning" + InstallationAuditLogLevelError InstallationAuditLogLevel = "error" +) + +type InstallationAuditLogEntry struct { + ID uuid.UUID + InstallationID uuid.UUID + Message string + Level InstallationAuditLogLevel + IdempotencyKey *string + CreatedAt time.Time +} + +type AppendInstallationAuditLogEntryInput struct { + InstallationID uuid.UUID + Message string + Level InstallationAuditLogLevel + IdempotencyKey *string +} + +type auditLogCursor struct { + CreatedAt time.Time + ID uuid.UUID +} + +func scanInstallationAuditLogEntry(row pgx.Row) (InstallationAuditLogEntry, error) { + var entry InstallationAuditLogEntry + var idempotencyKey pgtype.Text + if err := row.Scan( + &entry.ID, + &entry.InstallationID, + &entry.Message, + &entry.Level, + &idempotencyKey, + &entry.CreatedAt, + ); err != nil { + return InstallationAuditLogEntry{}, err + } + if idempotencyKey.Valid { + entry.IdempotencyKey = &idempotencyKey.String + } + return entry, nil +} + +func (s *Store) AppendInstallationAuditLogEntry(ctx context.Context, input AppendInstallationAuditLogEntryInput) (InstallationAuditLogEntry, error) { + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return InstallationAuditLogEntry{}, err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock(hashtext($1))", input.InstallationID.String()); err != nil { + return InstallationAuditLogEntry{}, err + } + + if input.IdempotencyKey != nil { + cutoff := time.Now().Add(-auditLogIdempotencyWindow) + row := tx.QueryRow(ctx, + fmt.Sprintf(`SELECT %s FROM installation_audit_log_entries WHERE installation_id = $1 AND idempotency_key = $2 AND created_at >= $3 ORDER BY created_at DESC, id DESC LIMIT 1`, auditLogEntryColumns), + input.InstallationID, + *input.IdempotencyKey, + cutoff, + ) + entry, err := scanInstallationAuditLogEntry(row) + if err == nil { + if err := tx.Commit(ctx); err != nil { + return InstallationAuditLogEntry{}, err + } + return entry, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return InstallationAuditLogEntry{}, err + } + } + + row := tx.QueryRow(ctx, + fmt.Sprintf(`INSERT INTO installation_audit_log_entries (installation_id, message, level, idempotency_key) VALUES ($1, $2, $3, $4) RETURNING %s`, auditLogEntryColumns), + input.InstallationID, + input.Message, + input.Level, + input.IdempotencyKey, + ) + entry, err := scanInstallationAuditLogEntry(row) + if err != nil { + return InstallationAuditLogEntry{}, err + } + + if _, err := tx.Exec(ctx, + `WITH doomed AS ( + SELECT id FROM installation_audit_log_entries + WHERE installation_id = $1 + ORDER BY created_at DESC, id DESC + OFFSET $2 + ) + DELETE FROM installation_audit_log_entries WHERE id IN (SELECT id FROM doomed)`, + input.InstallationID, + auditLogRetentionLimit, + ); err != nil { + return InstallationAuditLogEntry{}, err + } + + if err := tx.Commit(ctx); err != nil { + return InstallationAuditLogEntry{}, err + } + return entry, nil +} + +func (s *Store) ListInstallationAuditLogEntries(ctx context.Context, installationID uuid.UUID, pageSize int, pageToken string) ([]InstallationAuditLogEntry, string, error) { + limit := normalizePageSize(pageSize) + + args := []any{installationID} + conditions := []string{"installation_id = $1"} + argID := 1 + + if pageToken != "" { + cursor, err := decodeAuditLogPageToken(pageToken) + if err != nil { + return nil, "", InvalidPageToken(err) + } + args = append(args, cursor.CreatedAt, cursor.ID) + conditions = append(conditions, fmt.Sprintf("(created_at < $%d OR (created_at = $%d AND id < $%d))", argID+1, argID+1, argID+2)) + argID += 2 + } + + query := fmt.Sprintf( + "SELECT %s FROM installation_audit_log_entries WHERE %s ORDER BY created_at DESC, id DESC LIMIT $%d", + auditLogEntryColumns, + strings.Join(conditions, " AND "), + argID+1, + ) + args = append(args, limit+1) + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, "", err + } + defer rows.Close() + + entries := make([]InstallationAuditLogEntry, 0, limit) + var ( + lastEntry InstallationAuditLogEntry + hasMore bool + ) + for rows.Next() { + if len(entries) == limit { + hasMore = true + break + } + entry, err := scanInstallationAuditLogEntry(rows) + if err != nil { + return nil, "", err + } + entries = append(entries, entry) + lastEntry = entry + } + if err := rows.Err(); err != nil { + return nil, "", err + } + + nextToken := "" + if hasMore { + nextToken = encodeAuditLogPageToken(lastEntry) + } + return entries, nextToken, nil +} + +func encodeAuditLogPageToken(entry InstallationAuditLogEntry) string { + payload := fmt.Sprintf("%d|%s", entry.CreatedAt.UnixNano(), entry.ID.String()) + return base64.RawURLEncoding.EncodeToString([]byte(payload)) +} + +func decodeAuditLogPageToken(token string) (auditLogCursor, error) { + if token == "" { + return auditLogCursor{}, errors.New("empty token") + } + decoded, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return auditLogCursor{}, fmt.Errorf("decode token: %w", err) + } + parts := strings.SplitN(string(decoded), "|", 2) + if len(parts) != 2 { + return auditLogCursor{}, errors.New("invalid token") + } + nanos, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return auditLogCursor{}, fmt.Errorf("parse timestamp: %w", err) + } + id, err := uuid.Parse(parts[1]) + if err != nil { + return auditLogCursor{}, fmt.Errorf("parse id: %w", err) + } + return auditLogCursor{CreatedAt: time.Unix(0, nanos), ID: id}, nil +} diff --git a/internal/store/store.go b/internal/store/store.go index b780908..dbb7512 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -11,12 +11,13 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" ) const ( appColumns = `id, slug, name, description, icon, identity_id, service_token_hash, ziti_identity_id, ziti_service_id, organization_id, visibility, permissions, created_at, updated_at` - installationColumns = `id, app_id, organization_id, slug, configuration, created_at, updated_at` + installationColumns = `id, app_id, organization_id, slug, configuration, status, created_at, updated_at` defaultListPageSize = 50 maxListPageSize = 100 @@ -79,6 +80,7 @@ type Installation struct { OrganizationID uuid.UUID Slug string Configuration map[string]any + Status *string } type CreateInstallationInput struct { @@ -138,17 +140,22 @@ func scanApp(row pgx.Row) (App, error) { func scanInstallation(row pgx.Row) (Installation, error) { var installation Installation + var status pgtype.Text if err := row.Scan( &installation.Meta.ID, &installation.AppID, &installation.OrganizationID, &installation.Slug, &installation.Configuration, + &status, &installation.Meta.CreatedAt, &installation.Meta.UpdatedAt, ); err != nil { return Installation{}, err } + if status.Valid { + installation.Status = &status.String + } return installation, nil } @@ -543,6 +550,22 @@ func (s *Store) UpdateInstallation(ctx context.Context, input UpdateInstallation return installation, nil } +func (s *Store) UpdateInstallationStatus(ctx context.Context, id uuid.UUID, status *string) (Installation, error) { + row := s.pool.QueryRow(ctx, + fmt.Sprintf("UPDATE app_installations SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING %s", installationColumns), + status, + id, + ) + installation, err := scanInstallation(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Installation{}, NotFound("installation") + } + return Installation{}, err + } + return installation, nil +} + func (s *Store) DeleteInstallation(ctx context.Context, id uuid.UUID) error { result, err := s.pool.Exec(ctx, `DELETE FROM app_installations WHERE id = $1`, id) if err != nil { diff --git a/migrations/0003_installation_status_audit_log.sql b/migrations/0003_installation_status_audit_log.sql new file mode 100644 index 0000000..eeebf8d --- /dev/null +++ b/migrations/0003_installation_status_audit_log.sql @@ -0,0 +1,19 @@ +-- 0003_installation_status_audit_log.sql + +-- 1. Add status field for installations. +ALTER TABLE app_installations ADD COLUMN status TEXT; + +-- 2. Create installation audit log entries table. +CREATE TABLE installation_audit_log_entries ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + installation_id UUID NOT NULL REFERENCES app_installations (id) ON DELETE CASCADE, + message TEXT NOT NULL, + level TEXT NOT NULL, + idempotency_key TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX installation_audit_log_entries_installation_id_created_at_idx + ON installation_audit_log_entries (installation_id, created_at DESC, id DESC); +CREATE INDEX installation_audit_log_entries_idempotency_idx + ON installation_audit_log_entries (installation_id, idempotency_key); From 1b81e10fc39925fc9fe0ecb00f744058e5bfefa9 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Tue, 21 Apr 2026 00:35:53 +0000 Subject: [PATCH 2/2] chore(buf): update api lock --- buf.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/buf.lock b/buf.lock index ca4c0ea..a631539 100644 --- a/buf.lock +++ b/buf.lock @@ -4,8 +4,8 @@ deps: - remote: buf.build owner: agynio repository: api - commit: b67ca28ce2c94ca1a95f27e18051641b - digest: shake256:fc939b54bfabac248969211ca6f70d6c4efe48c6e971644470c1a1eb2e49719208d270ce7b7319bace0ac279a1a8b2e842b146204a3d2600ab974c807c727ea3 + commit: bbd8e6ab60974e9eb324ba9c4eeda240 + digest: shake256:803c10c71ad07ed7b1f2cc531c499f4fdbf7598937f51ca7ed353ae67d8950f8f789ac23ac5bd3b2c2e1c623b1a68ff2835925bf3bd4c36356912f54804aebb7 - remote: buf.build owner: opentelemetry repository: opentelemetry