mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:22:07 +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
152 lines
5.3 KiB
Go
152 lines
5.3 KiB
Go
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
"github.com/certctl-io/certctl/internal/repository"
|
|
)
|
|
|
|
// ErrBulkReassignOwnerNotFound is the typed sentinel for a non-existent
|
|
// target OwnerID. The handler maps it to 400 (bad input — the operator
|
|
// picked an owner that doesn't exist) rather than 500 (server error).
|
|
// Sentinel-error rather than substring-error matches the project's
|
|
// post-M-1 error-mapping convention.
|
|
var ErrBulkReassignOwnerNotFound = errors.New("owner not found")
|
|
|
|
// BulkReassignmentService coordinates bulk owner-reassignment of
|
|
// certificates.
|
|
//
|
|
// L-2 closure (cat-l-8a1fb258a38a): the GUI used to loop
|
|
// `await updateCertificate(id, { owner_id })` over the selection at
|
|
// `web/src/pages/CertificatesPage.tsx::handleReassign`. Post-L-2 the
|
|
// GUI POSTs once. Narrower than BulkRenewal: explicit IDs only, no
|
|
// criteria-mode (criteria-mode reassignment doesn't have a strong use
|
|
// case — operators query first then reassign by ID).
|
|
//
|
|
// Validation order: empty IDs → 400, missing OwnerID → 400, OwnerID
|
|
// not in owners table → 400 (ErrBulkReassignOwnerNotFound). Resolving
|
|
// the owner upfront means we fail-fast without mutating any cert if
|
|
// the operator typo'd the owner ID.
|
|
type BulkReassignmentService struct {
|
|
certRepo repository.CertificateRepository
|
|
ownerRepo repository.OwnerRepository
|
|
auditService *AuditService
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewBulkReassignmentService creates a new BulkReassignmentService.
|
|
func NewBulkReassignmentService(
|
|
certRepo repository.CertificateRepository,
|
|
ownerRepo repository.OwnerRepository,
|
|
auditService *AuditService,
|
|
logger *slog.Logger,
|
|
) *BulkReassignmentService {
|
|
return &BulkReassignmentService{
|
|
certRepo: certRepo,
|
|
ownerRepo: ownerRepo,
|
|
auditService: auditService,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// BulkReassign updates owner_id (and optionally team_id) on every cert
|
|
// in request.CertificateIDs. Skips certs whose owner_id already equals
|
|
// the target (silent no-op — surfaced as TotalSkipped++, not as a fake
|
|
// "succeeded" count, so operators see "5 of your 10 selections were
|
|
// no-ops because Alice already owned them" without triaging fake
|
|
// errors).
|
|
//
|
|
// Partial failures don't abort the batch — the failing cert lands in
|
|
// Errors[]; the loop continues. Mirrors BulkRevocationService and
|
|
// BulkRenewalService partial-failure semantics.
|
|
//
|
|
// Audit: a single audit event is emitted at the end with the criteria
|
|
// + counts. NOT N events.
|
|
func (s *BulkReassignmentService) BulkReassign(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
|
|
if request.IsEmpty() {
|
|
return nil, fmt.Errorf("at least one certificate_id is required")
|
|
}
|
|
if request.OwnerID == "" {
|
|
return nil, fmt.Errorf("owner_id is required")
|
|
}
|
|
|
|
// Validate the target owner exists BEFORE touching any cert. This
|
|
// fail-fast pattern means an operator who typo'd 'o-alic' (missing
|
|
// 'e') doesn't half-reassign 50 certs before the 51st surfaces the
|
|
// FK violation.
|
|
if _, err := s.ownerRepo.Get(ctx, request.OwnerID); err != nil {
|
|
return nil, fmt.Errorf("%w: %s", ErrBulkReassignOwnerNotFound, request.OwnerID)
|
|
}
|
|
|
|
result := &domain.BulkReassignmentResult{}
|
|
|
|
for _, id := range request.CertificateIDs {
|
|
cert, err := s.certRepo.Get(ctx, id)
|
|
if err != nil {
|
|
result.TotalFailed++
|
|
result.Errors = append(result.Errors, domain.BulkOperationError{
|
|
CertificateID: id,
|
|
Error: fmt.Sprintf("failed to fetch certificate: %v", err),
|
|
})
|
|
continue
|
|
}
|
|
result.TotalMatched++
|
|
|
|
// No-op skip: cert already owned by the target. team_id may
|
|
// still differ — we still skip if owner matches AND
|
|
// team_id-update is a no-op (team unchanged or team_id field
|
|
// not set on the request). This prevents fake "reassigned"
|
|
// counts when nothing actually changed.
|
|
ownerUnchanged := cert.OwnerID == request.OwnerID
|
|
teamUnchanged := request.TeamID == "" || cert.TeamID == request.TeamID
|
|
if ownerUnchanged && teamUnchanged {
|
|
result.TotalSkipped++
|
|
continue
|
|
}
|
|
|
|
cert.OwnerID = request.OwnerID
|
|
if request.TeamID != "" {
|
|
cert.TeamID = request.TeamID
|
|
}
|
|
if err := s.certRepo.Update(ctx, cert); err != nil {
|
|
result.TotalFailed++
|
|
result.Errors = append(result.Errors, domain.BulkOperationError{
|
|
CertificateID: id,
|
|
Error: fmt.Sprintf("failed to update certificate: %v", err),
|
|
})
|
|
s.logger.Warn("bulk reassignment: update failed",
|
|
"certificate_id", id, "error", err)
|
|
continue
|
|
}
|
|
result.TotalReassigned++
|
|
}
|
|
|
|
// Single bulk audit event at the end.
|
|
auditDetails := map[string]interface{}{
|
|
"owner_id": request.OwnerID,
|
|
"certificate_ids": strings.Join(request.CertificateIDs, ","),
|
|
"total_matched": result.TotalMatched,
|
|
"total_reassigned": result.TotalReassigned,
|
|
"total_skipped": result.TotalSkipped,
|
|
"total_failed": result.TotalFailed,
|
|
}
|
|
if request.TeamID != "" {
|
|
auditDetails["team_id"] = request.TeamID
|
|
}
|
|
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
|
"bulk_reassignment_initiated", "certificate", "bulk",
|
|
auditDetails); err != nil {
|
|
s.logger.Error("failed to record bulk reassignment audit event", "error", err)
|
|
}
|
|
|
|
return result, nil
|
|
}
|