mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:11:32 +00:00
21aeed4f4e
Phase 0 closure (Path B2, post-rewrite):
addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).
Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.
Generated via:
addlicense -c "certctl LLC" -y 2026 \
-f cowork/legal/copyright-header.tpl \
-ignore '**/testdata/**' -ignore '**/*_test.go' \
cmd/ internal/
Verification:
find cmd internal -name '*.go' -not -name '*_test.go' \
-not -path '*/testdata/*' \
-exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l
Returns: 0
gofmt clean. Header additions are comments only, no compile impact.
Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
315 lines
11 KiB
Go
315 lines
11 KiB
Go
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
"github.com/certctl-io/certctl/internal/repository"
|
|
)
|
|
|
|
// AuditService provides business logic for recording and retrieving audit events.
|
|
type AuditService struct {
|
|
auditRepo repository.AuditRepository
|
|
}
|
|
|
|
// NewAuditService creates a new audit service.
|
|
func NewAuditService(auditRepo repository.AuditRepository) *AuditService {
|
|
return &AuditService{
|
|
auditRepo: auditRepo,
|
|
}
|
|
}
|
|
|
|
// RecordEvent records an audit event with actor, action, and resource information.
|
|
//
|
|
// Bundle-6 / Audit H-008 + M-022 / CWE-532: every details map flows through
|
|
// RedactDetailsForAudit BEFORE marshaling. The redactor scrubs credential
|
|
// keys (api_key, password, token, *_pem, eab_secret, ...) and PII keys
|
|
// (email, phone, ssn, name, address, ip_address, ...) and surfaces a
|
|
// `redacted_keys` array so operators can audit the redactor itself during
|
|
// a compliance review. See internal/service/audit_redact.go.
|
|
func (s *AuditService) RecordEvent(ctx context.Context, actor string, actorType domain.ActorType, action string, resourceType string, resourceID string, details map[string]interface{}) error {
|
|
return s.RecordEventWithCategory(ctx, actor, actorType, action, "", resourceType, resourceID, details)
|
|
}
|
|
|
|
// RecordEventWithCategory is the Bundle 1 Phase 8 categorized variant
|
|
// of RecordEvent. eventCategory is one of
|
|
// domain.EventCategoryCertLifecycle, domain.EventCategoryAuth,
|
|
// domain.EventCategoryConfig — empty defaults to cert_lifecycle in
|
|
// the persistence layer + DB CHECK constraint.
|
|
//
|
|
// Existing 90+ call sites that don't yet pass a category route
|
|
// through the legacy RecordEvent and inherit the cert_lifecycle
|
|
// default; new callers (auth handlers, bootstrap, config-mutation
|
|
// handlers) call this method directly with their explicit category.
|
|
// Both paths share the same redaction + marshaling contract.
|
|
func (s *AuditService) RecordEventWithCategory(ctx context.Context, actor string, actorType domain.ActorType, action, eventCategory, resourceType, resourceID string, details map[string]interface{}) error {
|
|
redacted := RedactDetailsForAudit(details)
|
|
detailsJSON, err := json.Marshal(redacted)
|
|
if err != nil {
|
|
detailsJSON = []byte("{}")
|
|
}
|
|
|
|
event := &domain.AuditEvent{
|
|
ID: generateID("audit"),
|
|
Timestamp: time.Now(),
|
|
Actor: actor,
|
|
ActorType: actorType,
|
|
Action: action,
|
|
ResourceType: resourceType,
|
|
ResourceID: resourceID,
|
|
Details: json.RawMessage(detailsJSON),
|
|
EventCategory: eventCategory,
|
|
}
|
|
|
|
if err := s.auditRepo.Create(ctx, event); err != nil {
|
|
return fmt.Errorf("failed to record audit event: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RecordEventWithTx records an audit event using the supplied repository.Querier.
|
|
//
|
|
// Pass *sql.Tx (typically obtained from postgres.WithinTx) to participate in
|
|
// a caller's transaction so the audit row is atomic with the operation that
|
|
// triggered it. Closes the #3 acquisition-readiness blocker from the
|
|
// 2026-05-01 issuer coverage audit (audit row not transactional with the
|
|
// operation it audits).
|
|
//
|
|
// Same redaction + marshalling contract as RecordEvent; only the database
|
|
// handle changes.
|
|
func (s *AuditService) RecordEventWithTx(ctx context.Context, q repository.Querier, actor string, actorType domain.ActorType, action string, resourceType string, resourceID string, details map[string]interface{}) error {
|
|
redacted := RedactDetailsForAudit(details)
|
|
detailsJSON, err := json.Marshal(redacted)
|
|
if err != nil {
|
|
detailsJSON = []byte("{}")
|
|
}
|
|
|
|
event := &domain.AuditEvent{
|
|
ID: generateID("audit"),
|
|
Timestamp: time.Now(),
|
|
Actor: actor,
|
|
ActorType: actorType,
|
|
Action: action,
|
|
ResourceType: resourceType,
|
|
ResourceID: resourceID,
|
|
Details: json.RawMessage(detailsJSON),
|
|
}
|
|
|
|
if err := s.auditRepo.CreateWithTx(ctx, q, event); err != nil {
|
|
return fmt.Errorf("failed to record audit event: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RecordEventWithCategoryWithTx records a categorized audit event using
|
|
// the supplied repository.Querier so the row is committed in the same
|
|
// transaction as the underlying action. Mirrors RecordEventWithCategory
|
|
// but takes the Querier (typically *sql.Tx from postgres.WithinTx).
|
|
//
|
|
// Audit 2026-05-10 HIGH-6 closure — closes the gap where Bundle-1+2
|
|
// auth-mutation paths emitted the audit row via a separate, non-
|
|
// transactional connection. A DB hiccup or connection reset between
|
|
// the action and the audit-row INSERT used to leave the action
|
|
// committed with no audit trail (CWE-778). With this method, the
|
|
// audit row participates in the action's transaction: rollback on
|
|
// any failure removes both the action row AND any audit row that the
|
|
// caller wrote inside the tx.
|
|
func (s *AuditService) RecordEventWithCategoryWithTx(ctx context.Context, q repository.Querier, actor string, actorType domain.ActorType, action, eventCategory, resourceType, resourceID string, details map[string]interface{}) error {
|
|
redacted := RedactDetailsForAudit(details)
|
|
detailsJSON, err := json.Marshal(redacted)
|
|
if err != nil {
|
|
detailsJSON = []byte("{}")
|
|
}
|
|
|
|
event := &domain.AuditEvent{
|
|
ID: generateID("audit"),
|
|
Timestamp: time.Now(),
|
|
Actor: actor,
|
|
ActorType: actorType,
|
|
Action: action,
|
|
ResourceType: resourceType,
|
|
ResourceID: resourceID,
|
|
Details: json.RawMessage(detailsJSON),
|
|
EventCategory: eventCategory,
|
|
}
|
|
|
|
if err := s.auditRepo.CreateWithTx(ctx, q, event); err != nil {
|
|
return fmt.Errorf("failed to record audit event: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// List returns audit events matching filter criteria.
|
|
func (s *AuditService) List(ctx context.Context, filter *repository.AuditFilter) ([]*domain.AuditEvent, error) {
|
|
events, err := s.auditRepo.List(ctx, filter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list audit events: %w", err)
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
// ListByResource returns all audit events for a specific resource.
|
|
func (s *AuditService) ListByResource(ctx context.Context, resourceType string, resourceID string) ([]*domain.AuditEvent, error) {
|
|
filter := &repository.AuditFilter{
|
|
ResourceType: resourceType,
|
|
ResourceID: resourceID,
|
|
PerPage: 1000, // reasonable default for single resource
|
|
}
|
|
|
|
events, err := s.auditRepo.List(ctx, filter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list audit events: %w", err)
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
// ListByActor returns all audit events for a specific actor.
|
|
func (s *AuditService) ListByActor(ctx context.Context, actor string) ([]*domain.AuditEvent, error) {
|
|
filter := &repository.AuditFilter{
|
|
Actor: actor,
|
|
PerPage: 1000,
|
|
}
|
|
|
|
events, err := s.auditRepo.List(ctx, filter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list audit events: %w", err)
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
// ListByAction returns all audit events for a specific action type.
|
|
func (s *AuditService) ListByAction(ctx context.Context, action string, from, to time.Time) ([]*domain.AuditEvent, error) {
|
|
filter := &repository.AuditFilter{
|
|
From: from,
|
|
To: to,
|
|
PerPage: 1000,
|
|
}
|
|
|
|
events, err := s.auditRepo.List(ctx, filter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list audit events: %w", err)
|
|
}
|
|
|
|
// Filter by action on client side (repository may not filter by action directly)
|
|
var filtered []*domain.AuditEvent
|
|
for _, e := range events {
|
|
if e.Action == action {
|
|
filtered = append(filtered, e)
|
|
}
|
|
}
|
|
|
|
return filtered, nil
|
|
}
|
|
|
|
// ListAuditEvents returns paginated audit events (handler interface method).
|
|
func (s *AuditService) ListAuditEvents(ctx context.Context, page, perPage int) ([]domain.AuditEvent, int64, error) {
|
|
return s.ListAuditEventsByCategory(ctx, "", page, perPage)
|
|
}
|
|
|
|
// ListAuditEventsByCategory is the Bundle 1 Phase 8 categorized variant.
|
|
// Empty eventCategory disables the filter.
|
|
func (s *AuditService) ListAuditEventsByCategory(ctx context.Context, eventCategory string, page, perPage int) ([]domain.AuditEvent, int64, error) {
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 {
|
|
perPage = 50
|
|
}
|
|
|
|
filter := &repository.AuditFilter{
|
|
EventCategory: eventCategory,
|
|
Page: page,
|
|
PerPage: perPage,
|
|
}
|
|
|
|
events, err := s.auditRepo.List(ctx, filter)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to list audit events: %w", err)
|
|
}
|
|
|
|
// Convert pointers to values for the handler interface
|
|
var result []domain.AuditEvent
|
|
for _, e := range events {
|
|
if e != nil {
|
|
result = append(result, *e)
|
|
}
|
|
}
|
|
|
|
// see #audit-pagination-count — the repository currently returns
|
|
// the full filtered slice and we surface len(result) as total. This
|
|
// works for the audit page's current shape (server-side filter +
|
|
// client-side pagination over a bounded window) but is wrong when the
|
|
// frontend ports to server-side cursoring (Phase 9 P-H2). At that
|
|
// point the repository must add a CountAuditEvents(filter) method and
|
|
// this line becomes total, _ := s.repo.CountAuditEvents(ctx, filter).
|
|
total := int64(len(result))
|
|
|
|
return result, total, nil
|
|
}
|
|
|
|
// ExportEventsByFilter returns audit events matching a date-range +
|
|
// optional category filter without pagination — the export handler
|
|
// uses this to stream NDJSON for compliance evidence collection.
|
|
//
|
|
// Audit 2026-05-10 HIGH-11 closure: pre-fix, the `audit.export`
|
|
// permission was seeded into r-admin and r-auditor (migration 000031)
|
|
// but no endpoint enforced it — misleading capability advertisement.
|
|
// This method is the service-layer building block for the new
|
|
// GET /api/v1/audit/export endpoint.
|
|
//
|
|
// Bounded callers: the handler enforces a max 90-day range + max-rows
|
|
// cap before invoking this; the service-layer method itself is
|
|
// permissive so future callers (compliance-job runner, MCP tool) can
|
|
// reuse the helper without duplicating the bound enforcement.
|
|
func (s *AuditService) ExportEventsByFilter(ctx context.Context, from, to time.Time, eventCategory string, maxRows int) ([]domain.AuditEvent, error) {
|
|
if maxRows <= 0 {
|
|
maxRows = 50000
|
|
}
|
|
filter := &repository.AuditFilter{
|
|
EventCategory: eventCategory,
|
|
From: from,
|
|
To: to,
|
|
Page: 1,
|
|
PerPage: maxRows,
|
|
}
|
|
events, err := s.auditRepo.List(ctx, filter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list audit events for export: %w", err)
|
|
}
|
|
out := make([]domain.AuditEvent, 0, len(events))
|
|
for _, e := range events {
|
|
if e != nil {
|
|
out = append(out, *e)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GetAuditEvent returns a single audit event (handler interface method).
|
|
func (s *AuditService) GetAuditEvent(ctx context.Context, id string) (*domain.AuditEvent, error) {
|
|
filter := &repository.AuditFilter{
|
|
ResourceID: id,
|
|
PerPage: 1,
|
|
}
|
|
|
|
events, err := s.auditRepo.List(ctx, filter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get audit event: %w", err)
|
|
}
|
|
|
|
if len(events) == 0 {
|
|
return nil, fmt.Errorf("audit event not found")
|
|
}
|
|
|
|
return events[0], nil
|
|
}
|