mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:01:34 +00:00
8b75e0311b
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.
Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.
Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).
Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.
Diff shape:
361 *.go files — import path replacement only
2 go.mod — module declaration replacement only
1 binary — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
so embedded build-info reflects the new path (8618965 vs
8618933 bytes; 32-byte diff is the build-info change)
Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
mechanical substitution.
Verification:
gofmt: 17 files needed re-alignment after sed (the new path is one char
shorter than the old, so column-aligned import groups drifted). Applied
`gofmt -w` to fix.
go mod tidy: clean exit on both modules.
go vet ./...: clean exit.
go build ./...: clean exit.
go test -short -count=1 on representative packages: all green
(internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
confirming the module path resolves correctly.
binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
nothing; `strings | grep certctl-io/certctl` shows the new module path
embedded in build-info.
Files intentionally NOT touched in this commit:
README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
purely the Go-tooling layer.
Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
namespace, not a Go import or GitHub repo URL. Stays.
This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
511 lines
17 KiB
Go
511 lines
17 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
"github.com/certctl-io/certctl/internal/repository"
|
|
)
|
|
|
|
// PolicyService provides business logic for compliance policy management.
|
|
type PolicyService struct {
|
|
policyRepo repository.PolicyRepository
|
|
auditService *AuditService
|
|
// certRepo is optional and only required by the CertificateLifetime rule
|
|
// arm, which must read NotBefore/NotAfter from the latest CertificateVersion.
|
|
// Wire via SetCertRepo after construction; rules other than
|
|
// CertificateLifetime operate without it.
|
|
certRepo repository.CertificateRepository
|
|
}
|
|
|
|
// NewPolicyService creates a new policy service.
|
|
func NewPolicyService(
|
|
policyRepo repository.PolicyRepository,
|
|
auditService *AuditService,
|
|
) *PolicyService {
|
|
return &PolicyService{
|
|
policyRepo: policyRepo,
|
|
auditService: auditService,
|
|
}
|
|
}
|
|
|
|
// SetCertRepo wires the certificate repository needed for the CertificateLifetime
|
|
// rule arm. Kept as a setter (not a constructor parameter) so the ~36 existing
|
|
// NewPolicyService call sites don't churn for a single new arm's dependency.
|
|
// Safe to call before or after construction; evaluateRule checks for nil and
|
|
// returns an error if a CertificateLifetime rule fires without a wired repo
|
|
// (the caller at ValidateCertificate logs and continues).
|
|
func (s *PolicyService) SetCertRepo(r repository.CertificateRepository) {
|
|
s.certRepo = r
|
|
}
|
|
|
|
// ValidateCertificate runs all enabled policy rules against a certificate.
|
|
func (s *PolicyService) ValidateCertificate(ctx context.Context, cert *domain.ManagedCertificate) ([]*domain.PolicyViolation, error) {
|
|
rules, err := s.policyRepo.ListRules(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list policy rules: %w", err)
|
|
}
|
|
|
|
var violations []*domain.PolicyViolation
|
|
|
|
for _, rule := range rules {
|
|
// Skip disabled rules
|
|
if !rule.Enabled {
|
|
continue
|
|
}
|
|
|
|
// Evaluate rule against certificate
|
|
v, err := s.evaluateRule(ctx, rule, cert)
|
|
if err != nil {
|
|
slog.Error("failed to evaluate rule", "rule_id", rule.ID, "error", err)
|
|
continue
|
|
}
|
|
|
|
if v != nil {
|
|
violations = append(violations, v)
|
|
}
|
|
}
|
|
|
|
return violations, nil
|
|
}
|
|
|
|
// evaluateRule checks if a certificate violates a single policy rule.
|
|
//
|
|
// D-008 closes the engine loop by:
|
|
// 1. Consuming rule.Severity on every violation (the pre-D-008 engine
|
|
// hardcoded PolicySeverityWarning, which silently defeated the D-006
|
|
// per-rule severity column).
|
|
// 2. Parsing rule.Config per-arm so rules carry real thresholds / allowlists
|
|
// instead of the pre-D-008 "metadata absent" placeholders. Empty/null
|
|
// Config preserves the pre-D-008 missing-field behavior as a
|
|
// backward-compat invariant — a rule without config still fires on the
|
|
// absent-field shape but using its configured severity.
|
|
// 3. Adding the CertificateLifetime arm, which reads NotBefore/NotAfter from
|
|
// the latest CertificateVersion (injected via SetCertRepo). Required
|
|
// because ManagedCertificate tracks ExpiresAt but not issuance date.
|
|
//
|
|
// Bad-config failure mode: json.Unmarshal error returns (nil, error) shaped
|
|
// as `invalid config for rule <id> (type=<type>): <err>`; the caller at
|
|
// ValidateCertificate logs and continues so one malformed rule doesn't fail
|
|
// the entire pass.
|
|
func (s *PolicyService) evaluateRule(ctx context.Context, rule *domain.PolicyRule, cert *domain.ManagedCertificate) (*domain.PolicyViolation, error) {
|
|
switch rule.Type {
|
|
case domain.PolicyTypeAllowedIssuers:
|
|
// Config: {"allowed_issuer_ids": ["iss-a", "iss-b"]}
|
|
// Empty config = fire only on absent IssuerID (backward-compat).
|
|
var cfg struct {
|
|
AllowedIssuerIDs []string `json:"allowed_issuer_ids"`
|
|
}
|
|
if len(rule.Config) > 0 {
|
|
if err := json.Unmarshal(rule.Config, &cfg); err != nil {
|
|
return nil, fmt.Errorf("invalid config for rule %s (type=%s): %w", rule.ID, rule.Type, err)
|
|
}
|
|
}
|
|
if cert.IssuerID == "" {
|
|
return s.violation(rule, cert, "certificate has no issuer assigned"), nil
|
|
}
|
|
if len(cfg.AllowedIssuerIDs) > 0 && !containsString(cfg.AllowedIssuerIDs, cert.IssuerID) {
|
|
return s.violation(rule, cert, fmt.Sprintf("issuer %q is not in the allowed list", cert.IssuerID)), nil
|
|
}
|
|
|
|
case domain.PolicyTypeAllowedDomains:
|
|
// Config: {"allowed_domains": ["example.com", "*.internal.example.com"]}
|
|
// Wildcards are literal prefix matches (*.foo matches anything ending
|
|
// in .foo). Empty config = fire only on zero SANs (backward-compat).
|
|
var cfg struct {
|
|
AllowedDomains []string `json:"allowed_domains"`
|
|
}
|
|
if len(rule.Config) > 0 {
|
|
if err := json.Unmarshal(rule.Config, &cfg); err != nil {
|
|
return nil, fmt.Errorf("invalid config for rule %s (type=%s): %w", rule.ID, rule.Type, err)
|
|
}
|
|
}
|
|
if len(cert.SANs) == 0 {
|
|
return s.violation(rule, cert, "certificate has no subject alternative names"), nil
|
|
}
|
|
if len(cfg.AllowedDomains) > 0 {
|
|
for _, san := range cert.SANs {
|
|
if !domainAllowed(san, cfg.AllowedDomains) {
|
|
return s.violation(rule, cert, fmt.Sprintf("SAN %q is not in the allowed domain list", san)), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
case domain.PolicyTypeRequiredMetadata:
|
|
// Config: {"required_keys": ["owner", "cost-center"]}
|
|
// Empty config = fire only on zero tags (backward-compat).
|
|
var cfg struct {
|
|
RequiredKeys []string `json:"required_keys"`
|
|
}
|
|
if len(rule.Config) > 0 {
|
|
if err := json.Unmarshal(rule.Config, &cfg); err != nil {
|
|
return nil, fmt.Errorf("invalid config for rule %s (type=%s): %w", rule.ID, rule.Type, err)
|
|
}
|
|
}
|
|
if len(cert.Tags) == 0 {
|
|
return s.violation(rule, cert, "certificate has no tags or metadata"), nil
|
|
}
|
|
for _, key := range cfg.RequiredKeys {
|
|
if _, ok := cert.Tags[key]; !ok {
|
|
return s.violation(rule, cert, fmt.Sprintf("certificate is missing required metadata key %q", key)), nil
|
|
}
|
|
}
|
|
|
|
case domain.PolicyTypeAllowedEnvironments:
|
|
// Config: {"allowed": ["prod", "staging"]}
|
|
// Empty config = fire only on empty Environment (backward-compat).
|
|
var cfg struct {
|
|
Allowed []string `json:"allowed"`
|
|
}
|
|
if len(rule.Config) > 0 {
|
|
if err := json.Unmarshal(rule.Config, &cfg); err != nil {
|
|
return nil, fmt.Errorf("invalid config for rule %s (type=%s): %w", rule.ID, rule.Type, err)
|
|
}
|
|
}
|
|
if cert.Environment == "" {
|
|
return s.violation(rule, cert, "certificate has no environment assigned"), nil
|
|
}
|
|
if len(cfg.Allowed) > 0 && !containsString(cfg.Allowed, cert.Environment) {
|
|
return s.violation(rule, cert, fmt.Sprintf("environment %q is not in the allowed list", cert.Environment)), nil
|
|
}
|
|
|
|
case domain.PolicyTypeRenewalLeadTime:
|
|
// Config: {"lead_time_days": 30}
|
|
// Fires when remaining validity drops below lead_time_days and the
|
|
// cert is not already expired. Empty/zero config falls back to the
|
|
// pre-D-008 hardcoded 30-day threshold for backward compatibility.
|
|
var cfg struct {
|
|
LeadTimeDays int `json:"lead_time_days"`
|
|
}
|
|
if len(rule.Config) > 0 {
|
|
if err := json.Unmarshal(rule.Config, &cfg); err != nil {
|
|
return nil, fmt.Errorf("invalid config for rule %s (type=%s): %w", rule.ID, rule.Type, err)
|
|
}
|
|
}
|
|
leadDays := cfg.LeadTimeDays
|
|
if leadDays <= 0 {
|
|
leadDays = 30
|
|
}
|
|
daysUntilExpiry := time.Until(cert.ExpiresAt).Hours() / 24
|
|
if daysUntilExpiry < float64(leadDays) && daysUntilExpiry > 0 {
|
|
return s.violation(rule, cert, fmt.Sprintf("certificate expires in %.1f days, plan renewal soon (policy lead time: %d days)", daysUntilExpiry, leadDays)), nil
|
|
}
|
|
|
|
case domain.PolicyTypeCertificateLifetime:
|
|
// Config: {"max_days": 397}
|
|
// Reads NotBefore/NotAfter from the latest CertificateVersion via the
|
|
// injected certRepo. ManagedCertificate exposes ExpiresAt but not the
|
|
// issuance date, so lifetime math requires the version record.
|
|
//
|
|
// If certRepo wasn't wired (test misconfiguration / early boot),
|
|
// returns an error so the caller logs it — better a loud failure
|
|
// than silently ignoring the rule. If GetLatestVersion errors (e.g.,
|
|
// the cert hasn't been issued yet), we skip the check — a cert with
|
|
// no version has no lifetime to measure, matching the missing-field
|
|
// backward-compat pattern used by the other arms.
|
|
if s.certRepo == nil {
|
|
return nil, fmt.Errorf("CertificateLifetime rule %s requires cert repository (not wired via SetCertRepo)", rule.ID)
|
|
}
|
|
var cfg struct {
|
|
MaxDays int `json:"max_days"`
|
|
}
|
|
if len(rule.Config) > 0 {
|
|
if err := json.Unmarshal(rule.Config, &cfg); err != nil {
|
|
return nil, fmt.Errorf("invalid config for rule %s (type=%s): %w", rule.ID, rule.Type, err)
|
|
}
|
|
}
|
|
if cfg.MaxDays <= 0 {
|
|
// No threshold configured — nothing meaningful to enforce.
|
|
return nil, nil
|
|
}
|
|
version, err := s.certRepo.GetLatestVersion(ctx, cert.ID)
|
|
if err != nil {
|
|
// No version yet — nothing to measure. Not an engine error;
|
|
// the cert simply hasn't been issued.
|
|
return nil, nil
|
|
}
|
|
lifetimeDays := version.NotAfter.Sub(version.NotBefore).Hours() / 24
|
|
if lifetimeDays > float64(cfg.MaxDays) {
|
|
return s.violation(rule, cert, fmt.Sprintf("certificate lifetime is %.1f days, exceeds policy max of %d days", lifetimeDays, cfg.MaxDays)), nil
|
|
}
|
|
|
|
default:
|
|
return nil, fmt.Errorf("unknown policy rule type: %s", rule.Type)
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// violation constructs a PolicyViolation carrying the rule's configured
|
|
// severity. Centralizing the build eliminates the pre-D-008 bug where each
|
|
// arm independently stamped PolicySeverityWarning on its violation.
|
|
func (s *PolicyService) violation(rule *domain.PolicyRule, cert *domain.ManagedCertificate, message string) *domain.PolicyViolation {
|
|
return &domain.PolicyViolation{
|
|
ID: generateID("violation"),
|
|
RuleID: rule.ID,
|
|
CertificateID: cert.ID,
|
|
Severity: rule.Severity,
|
|
Message: message,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
}
|
|
|
|
// containsString reports whether needle is present in haystack.
|
|
func containsString(haystack []string, needle string) bool {
|
|
for _, s := range haystack {
|
|
if s == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// domainAllowed reports whether a SAN (hostname) matches any of the allowed
|
|
// domain patterns. Patterns may be exact matches or `*.example.com` wildcards
|
|
// (the wildcard consumes a single label: `*.foo.com` matches `bar.foo.com`
|
|
// but not `baz.bar.foo.com`, mirroring X.509 SAN wildcard semantics).
|
|
func domainAllowed(san string, allowed []string) bool {
|
|
san = strings.ToLower(strings.TrimSpace(san))
|
|
for _, pattern := range allowed {
|
|
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
|
if pattern == san {
|
|
return true
|
|
}
|
|
if strings.HasPrefix(pattern, "*.") {
|
|
suffix := pattern[1:] // ".foo.com"
|
|
if strings.HasSuffix(san, suffix) {
|
|
// Ensure wildcard consumes exactly one label — reject
|
|
// sub-subdomains.
|
|
head := strings.TrimSuffix(san, suffix)
|
|
if head != "" && !strings.Contains(head, ".") {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// CreateRule stores a new policy rule.
|
|
func (s *PolicyService) CreateRule(ctx context.Context, rule *domain.PolicyRule, actor string) error {
|
|
if rule.ID == "" {
|
|
rule.ID = generateID("rule")
|
|
}
|
|
if rule.CreatedAt.IsZero() {
|
|
rule.CreatedAt = time.Now()
|
|
}
|
|
|
|
if err := s.policyRepo.CreateRule(ctx, rule); err != nil {
|
|
return fmt.Errorf("failed to create policy rule: %w", err)
|
|
}
|
|
|
|
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
|
"policy_rule_created", "policy", rule.ID,
|
|
map[string]interface{}{"rule_type": rule.Type}); err != nil {
|
|
slog.Error("failed to record audit event", "error", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateRule modifies an existing policy rule.
|
|
func (s *PolicyService) UpdateRule(ctx context.Context, rule *domain.PolicyRule, actor string) error {
|
|
existing, err := s.policyRepo.GetRule(ctx, rule.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch existing rule: %w", err)
|
|
}
|
|
|
|
rule.UpdatedAt = time.Now()
|
|
|
|
if err := s.policyRepo.UpdateRule(ctx, rule); err != nil {
|
|
return fmt.Errorf("failed to update policy rule: %w", err)
|
|
}
|
|
|
|
changes := map[string]interface{}{}
|
|
if existing.Enabled != rule.Enabled {
|
|
changes["enabled"] = rule.Enabled
|
|
}
|
|
|
|
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
|
"policy_rule_updated", "policy", rule.ID, changes); err != nil {
|
|
slog.Error("failed to record audit event", "error", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetRule retrieves a policy rule by ID.
|
|
func (s *PolicyService) GetRule(ctx context.Context, id string) (*domain.PolicyRule, error) {
|
|
rule, err := s.policyRepo.GetRule(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch policy rule: %w", err)
|
|
}
|
|
return rule, nil
|
|
}
|
|
|
|
// ListRules returns all policy rules.
|
|
func (s *PolicyService) ListRules(ctx context.Context) ([]*domain.PolicyRule, error) {
|
|
rules, err := s.policyRepo.ListRules(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list policy rules: %w", err)
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
// DeleteRule removes a policy rule.
|
|
func (s *PolicyService) DeleteRule(ctx context.Context, id string, actor string) error {
|
|
rule, err := s.policyRepo.GetRule(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch rule: %w", err)
|
|
}
|
|
|
|
if err := s.policyRepo.DeleteRule(ctx, id); err != nil {
|
|
return fmt.Errorf("failed to delete policy rule: %w", err)
|
|
}
|
|
|
|
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
|
"policy_rule_deleted", "policy", id,
|
|
map[string]interface{}{"rule_type": rule.Type}); err != nil {
|
|
slog.Error("failed to record audit event", "error", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListViolationsWithContext returns policy violations matching filter criteria.
|
|
func (s *PolicyService) ListViolationsWithContext(ctx context.Context, filter *repository.AuditFilter) ([]*domain.PolicyViolation, error) {
|
|
violations, err := s.policyRepo.ListViolations(ctx, filter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list policy violations: %w", err)
|
|
}
|
|
return violations, nil
|
|
}
|
|
|
|
// ListPolicies returns paginated policies (handler interface method).
|
|
func (s *PolicyService) ListPolicies(ctx context.Context, page, perPage int) ([]domain.PolicyRule, int64, error) {
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 {
|
|
perPage = 50
|
|
}
|
|
|
|
rules, err := s.policyRepo.ListRules(ctx)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to list policies: %w", err)
|
|
}
|
|
|
|
total := int64(len(rules))
|
|
start := (page - 1) * perPage
|
|
if start >= int(total) {
|
|
return nil, total, nil
|
|
}
|
|
end := start + perPage
|
|
if end > int(total) {
|
|
end = int(total)
|
|
}
|
|
|
|
var result []domain.PolicyRule
|
|
for _, r := range rules[start:end] {
|
|
if r != nil {
|
|
result = append(result, *r)
|
|
}
|
|
}
|
|
|
|
return result, total, nil
|
|
}
|
|
|
|
// GetPolicy returns a single policy (handler interface method).
|
|
func (s *PolicyService) GetPolicy(ctx context.Context, id string) (*domain.PolicyRule, error) {
|
|
return s.policyRepo.GetRule(ctx, id)
|
|
}
|
|
|
|
// CreatePolicy creates a new policy (handler interface method).
|
|
func (s *PolicyService) CreatePolicy(ctx context.Context, policy domain.PolicyRule) (*domain.PolicyRule, error) {
|
|
if policy.ID == "" {
|
|
policy.ID = generateID("rule")
|
|
}
|
|
if policy.CreatedAt.IsZero() {
|
|
policy.CreatedAt = time.Now()
|
|
}
|
|
|
|
if err := s.policyRepo.CreateRule(ctx, &policy); err != nil {
|
|
return nil, fmt.Errorf("failed to create policy: %w", err)
|
|
}
|
|
return &policy, nil
|
|
}
|
|
|
|
// UpdatePolicy modifies a policy (handler interface method).
|
|
func (s *PolicyService) UpdatePolicy(ctx context.Context, id string, policy domain.PolicyRule) (*domain.PolicyRule, error) {
|
|
policy.ID = id
|
|
policy.UpdatedAt = time.Now()
|
|
|
|
// Severity is NOT NULL with a CHECK constraint at the DB level
|
|
// (migration 000013). If the client omits severity on a PUT (zero-value
|
|
// empty string after json.Decode), preserve the existing severity rather
|
|
// than letting the CHECK reject the write. Preserves partial-update
|
|
// semantics for the new column without changing the pre-existing behavior
|
|
// for Name/Type, which is out of scope for D-005/D-006.
|
|
if policy.Severity == "" {
|
|
existing, err := s.policyRepo.GetRule(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch existing rule for severity preservation: %w", err)
|
|
}
|
|
policy.Severity = existing.Severity
|
|
}
|
|
|
|
if err := s.policyRepo.UpdateRule(ctx, &policy); err != nil {
|
|
return nil, fmt.Errorf("failed to update policy: %w", err)
|
|
}
|
|
return &policy, nil
|
|
}
|
|
|
|
// DeletePolicy removes a policy (handler interface method).
|
|
func (s *PolicyService) DeletePolicy(ctx context.Context, id string) error {
|
|
return s.policyRepo.DeleteRule(ctx, id)
|
|
}
|
|
|
|
// ListViolations returns policy violations with pagination (handler interface method).
|
|
func (s *PolicyService) ListViolations(ctx context.Context, policyID string, page, perPage int) ([]domain.PolicyViolation, int64, error) {
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 {
|
|
perPage = 50
|
|
}
|
|
|
|
filter := &repository.AuditFilter{
|
|
ResourceID: policyID,
|
|
PerPage: 1000, // Get all violations for the policy
|
|
}
|
|
|
|
violations, err := s.policyRepo.ListViolations(ctx, filter)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to list violations: %w", err)
|
|
}
|
|
|
|
total := int64(len(violations))
|
|
start := (page - 1) * perPage
|
|
if start >= int(total) {
|
|
return nil, total, nil
|
|
}
|
|
end := start + perPage
|
|
if end > int(total) {
|
|
end = int(total)
|
|
}
|
|
|
|
var result []domain.PolicyViolation
|
|
for _, v := range violations[start:end] {
|
|
if v != nil {
|
|
result = append(result, *v)
|
|
}
|
|
}
|
|
|
|
return result, total, nil
|
|
}
|