feat: M11b — ownership tracking, agent groups, interactive renewal approval

Ownership: owners/teams GUI pages, notification email resolution via
resolveRecipient (owner_id → owner.email lookup). Agent groups: dynamic
device grouping by OS/arch/IP CIDR/version with manual include/exclude
membership, migration 000004, full CRUD stack (domain → repo → service →
handler → frontend). Interactive approval: AwaitingApproval job state,
approve/reject API endpoints with reason tracking. Tests: 12 agent group
handler tests, 8 approve/reject job handler tests, integration tests
updated for 13-param RegisterHandlers. Docs updated across architecture,
concepts, and seed data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shankar
2026-03-20 21:02:35 -04:00
parent 1ef16984eb
commit e445cbef22
27 changed files with 1774 additions and 21 deletions
+154
View File
@@ -0,0 +1,154 @@
package service
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// AgentGroupService provides business logic for agent group management.
type AgentGroupService struct {
groupRepo repository.AgentGroupRepository
auditService *AuditService
}
// NewAgentGroupService creates a new agent group service.
func NewAgentGroupService(
groupRepo repository.AgentGroupRepository,
auditService *AuditService,
) *AgentGroupService {
return &AgentGroupService{
groupRepo: groupRepo,
auditService: auditService,
}
}
// ListAgentGroups returns paginated agent groups (handler interface method).
func (s *AgentGroupService) ListAgentGroups(page, perPage int) ([]domain.AgentGroup, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
groups, err := s.groupRepo.List(context.Background())
if err != nil {
return nil, 0, fmt.Errorf("failed to list agent groups: %w", err)
}
total := int64(len(groups))
var result []domain.AgentGroup
for _, g := range groups {
if g != nil {
result = append(result, *g)
}
}
return result, total, nil
}
// GetAgentGroup returns a single agent group (handler interface method).
func (s *AgentGroupService) GetAgentGroup(id string) (*domain.AgentGroup, error) {
return s.groupRepo.Get(context.Background(), id)
}
// CreateAgentGroup creates a new agent group with validation (handler interface method).
func (s *AgentGroupService) CreateAgentGroup(group domain.AgentGroup) (*domain.AgentGroup, error) {
if err := validateAgentGroup(&group); err != nil {
return nil, err
}
if group.ID == "" {
group.ID = generateID("ag")
}
now := time.Now()
if group.CreatedAt.IsZero() {
group.CreatedAt = now
}
if group.UpdatedAt.IsZero() {
group.UpdatedAt = now
}
if err := s.groupRepo.Create(context.Background(), &group); err != nil {
return nil, fmt.Errorf("failed to create agent group: %w", err)
}
if s.auditService != nil {
if auditErr := s.auditService.RecordEvent(context.Background(), "api", domain.ActorTypeUser,
"create_agent_group", "agent_group", group.ID, nil); auditErr != nil {
slog.Error("failed to record audit event", "error", auditErr)
}
}
return &group, nil
}
// UpdateAgentGroup modifies an existing agent group (handler interface method).
func (s *AgentGroupService) UpdateAgentGroup(id string, group domain.AgentGroup) (*domain.AgentGroup, error) {
if err := validateAgentGroup(&group); err != nil {
return nil, err
}
group.ID = id
if err := s.groupRepo.Update(context.Background(), &group); err != nil {
return nil, fmt.Errorf("failed to update agent group: %w", err)
}
if s.auditService != nil {
if auditErr := s.auditService.RecordEvent(context.Background(), "api", domain.ActorTypeUser,
"update_agent_group", "agent_group", id, nil); auditErr != nil {
slog.Error("failed to record audit event", "error", auditErr)
}
}
return &group, nil
}
// DeleteAgentGroup removes an agent group (handler interface method).
func (s *AgentGroupService) DeleteAgentGroup(id string) error {
if err := s.groupRepo.Delete(context.Background(), id); err != nil {
return fmt.Errorf("failed to delete agent group: %w", err)
}
if s.auditService != nil {
if auditErr := s.auditService.RecordEvent(context.Background(), "api", domain.ActorTypeUser,
"delete_agent_group", "agent_group", id, nil); auditErr != nil {
slog.Error("failed to record audit event", "error", auditErr)
}
}
return nil
}
// ListMembers returns agents in a group.
func (s *AgentGroupService) ListMembers(id string) ([]domain.Agent, int64, error) {
agents, err := s.groupRepo.ListMembers(context.Background(), id)
if err != nil {
return nil, 0, fmt.Errorf("failed to list group members: %w", err)
}
var result []domain.Agent
for _, a := range agents {
if a != nil {
result = append(result, *a)
}
}
return result, int64(len(result)), nil
}
// validateAgentGroup checks that an agent group's configuration is valid.
func validateAgentGroup(g *domain.AgentGroup) error {
if g.Name == "" {
return fmt.Errorf("agent group name is required")
}
if len(g.Name) > 255 {
return fmt.Errorf("agent group name exceeds 255 characters")
}
return nil
}
+47
View File
@@ -249,3 +249,50 @@ func (s *JobService) ListJobs(status, jobType string, page, perPage int) ([]doma
func (s *JobService) GetJob(id string) (*domain.Job, error) {
return s.jobRepo.Get(context.Background(), id)
}
// ApproveJob approves a renewal job that is awaiting approval.
// Transitions the job from AwaitingApproval to Pending so the scheduler picks it up.
func (s *JobService) ApproveJob(id string) error {
ctx := context.Background()
job, err := s.jobRepo.Get(ctx, id)
if err != nil {
return fmt.Errorf("job not found: %w", err)
}
if job.Status != domain.JobStatusAwaitingApproval {
return fmt.Errorf("cannot approve job with status %s (must be AwaitingApproval)", job.Status)
}
if err := s.jobRepo.UpdateStatus(ctx, id, domain.JobStatusPending, ""); err != nil {
return fmt.Errorf("failed to approve job: %w", err)
}
s.logger.Info("renewal job approved", "job_id", id, "certificate_id", job.CertificateID)
return nil
}
// RejectJob rejects a renewal job that is awaiting approval.
// Transitions the job to Cancelled with a rejection reason.
func (s *JobService) RejectJob(id string, reason string) error {
ctx := context.Background()
job, err := s.jobRepo.Get(ctx, id)
if err != nil {
return fmt.Errorf("job not found: %w", err)
}
if job.Status != domain.JobStatusAwaitingApproval {
return fmt.Errorf("cannot reject job with status %s (must be AwaitingApproval)", job.Status)
}
msg := "rejected by user"
if reason != "" {
msg = "rejected: " + reason
}
if err := s.jobRepo.UpdateStatus(ctx, id, domain.JobStatusCancelled, msg); err != nil {
return fmt.Errorf("failed to reject job: %w", err)
}
s.logger.Info("renewal job rejected", "job_id", id, "certificate_id", job.CertificateID, "reason", reason)
return nil
}
+24 -4
View File
@@ -13,6 +13,7 @@ import (
// NotificationService provides business logic for managing notifications.
type NotificationService struct {
notifRepo repository.NotificationRepository
ownerRepo repository.OwnerRepository
notifierRegistry map[string]Notifier
}
@@ -35,6 +36,25 @@ func NewNotificationService(
}
}
// SetOwnerRepo sets the owner repository for email resolution.
// Called after construction to avoid circular dependency during initialization.
func (s *NotificationService) SetOwnerRepo(ownerRepo repository.OwnerRepository) {
s.ownerRepo = ownerRepo
}
// resolveRecipient resolves an owner ID to an email address.
// Falls back to the raw owner ID if the owner repo is not set or lookup fails.
func (s *NotificationService) resolveRecipient(ctx context.Context, ownerID string) string {
if s.ownerRepo == nil || ownerID == "" {
return ownerID
}
owner, err := s.ownerRepo.Get(ctx, ownerID)
if err != nil || owner == nil || owner.Email == "" {
return ownerID
}
return owner.Email
}
// SendExpirationWarning sends a certificate expiration warning for a specific threshold.
func (s *NotificationService) SendExpirationWarning(ctx context.Context, cert *domain.ManagedCertificate, daysUntilExpiry int) error {
return s.SendThresholdAlert(ctx, cert, daysUntilExpiry, daysUntilExpiry)
@@ -56,13 +76,13 @@ func (s *NotificationService) SendThresholdAlert(ctx context.Context, cert *doma
)
}
// Create notification record
// Create notification record — resolve owner email if possible
notif := &domain.NotificationEvent{
ID: generateID("notif"),
CertificateID: &cert.ID,
Type: domain.NotificationTypeExpirationWarning,
Channel: domain.NotificationChannelEmail,
Recipient: cert.OwnerID,
Recipient: s.resolveRecipient(ctx, cert.OwnerID),
Message: body,
Status: "pending",
CreatedAt: time.Now(),
@@ -121,7 +141,7 @@ func (s *NotificationService) SendRenewalNotification(ctx context.Context, cert
CertificateID: &cert.ID,
Type: notifType,
Channel: domain.NotificationChannelEmail,
Recipient: cert.OwnerID,
Recipient: s.resolveRecipient(ctx, cert.OwnerID),
Message: body,
Status: "pending",
CreatedAt: time.Now(),
@@ -160,7 +180,7 @@ func (s *NotificationService) SendDeploymentNotification(ctx context.Context, ce
CertificateID: &cert.ID,
Type: notifType,
Channel: domain.NotificationChannelEmail,
Recipient: cert.OwnerID,
Recipient: s.resolveRecipient(ctx, cert.OwnerID),
Message: body,
Status: "pending",
CreatedAt: time.Now(),