mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:41:31 +00:00
fix(policies): stop 400ing the "+ New Policy" button + add per-rule severity (D-005, D-006)
Coverage Gap Audit findings D-005 (P0) + D-006 (P1) fixed together in a
single commit because they share the same root cause — policy CRUD sending
values the backend silently rejects — and splitting them would leave a
half-working UI between commits.
## D-005 (P0): PoliciesPage dropdown 400s every Create Policy
Root cause
----------
`web/src/pages/PoliciesPage.tsx` populated the Type `<select>` from a
hardcoded `['key_algorithm', 'ownership', 'allowed_issuers', ...]` array.
The backend's `internal/api/handler/validators.go::ValidatePolicyType`
enforces the TitleCase allowlist `AllowedIssuers`, `AllowedDomains`,
`RequiredMetadata`, `AllowedEnvironments`, `RenewalLeadTime` — defined in
`internal/domain/policy.go`. Every Create Policy request was rejected with
`400 invalid policy type`. The error surfaced only as a transient toast;
the modal closed anyway. Silent user-visible failure.
Fix
---
- `web/src/api/types.ts`: added `POLICY_TYPES` and `POLICY_SEVERITIES`
tuples with `as const` and narrowed `PolicyRule.type`, `.severity`, and
`PolicyViolation.severity` to the literal-union types. Dropdown is now
sourced from the tuple; casing drift becomes a compile error.
- `web/src/pages/PoliciesPage.tsx`: rekeyed `severityStyles` /
`severityDots` to the TitleCase values, added `humanize()` for display
(AllowedIssuers → "Allowed Issuers"), removed the `badge-neutral`
fallback that was papering over the mismatch.
- `web/src/api/types.test.ts` (new): pins both tuples exactly. If anyone
edits one side of the frontend/backend contract without the other, CI
fails with a clear assertion. Pure-TS vitest, no RTL dependency.
## D-006 (P1): `severity` field silently dropped on create/update
Root cause
----------
`PolicyRule` had no `Severity` field in `internal/domain/policy.go`. The
frontend has always sent `severity` on create/update, but Go's
`json.Decoder` (default settings, no `DisallowUnknownFields`) silently
dropped it. The value never reached PostgreSQL. Every rule rendered with
the same severity because there was no severity — just a display
computation downstream.
Fix: option (b), full-stack schema add (not delete-the-field)
-------------------------------------------------------------
- Migration `000013_policy_rule_severity` (up + down): adds
`severity VARCHAR(50) NOT NULL DEFAULT 'Warning'` to `policy_rules` with
CHECK constraint `severity IN ('Warning', 'Error', 'Critical')`. No
index — three-value column on a low-thousands-rows table, planner will
seq-scan regardless. PG 11+ metadata-only ADD COLUMN, safe on live data.
- `internal/domain/policy.go`: added `Severity PolicySeverity` field.
- `internal/repository/postgres/policy.go`: plumbed `severity` through
ListRules SELECT + Scan, GetRule SELECT + Scan, CreateRule INSERT,
UpdateRule UPDATE (4 queries).
- `internal/service/policy.go::UpdatePolicy`: if the client omits
severity on a PUT (zero-value empty string), fetch the existing rule
and preserve its severity. Without this, partial updates would trip the
NOT NULL CHECK and 500. Preserves pre-existing behavior for Name/Type
(out of scope).
- `internal/api/handler/policies.go::CreatePolicy`: default empty severity
to `'Warning'`, then validate via `ValidatePolicySeverity`. 400 with
clear message instead of 500 on CHECK violation. `UpdatePolicy`:
validates severity only when provided.
- `internal/mcp/types.go` + `internal/mcp/tools.go`: added optional
`severity` on the MCP `create_policy` / `update_policy` tool inputs so
LLM callers stay in sync with the wire contract.
- `api/openapi.yaml`: added `severity` to the `PolicyRule` schema with
the enum and default.
Acceptance criterion (user-defined)
-----------------------------------
"Create a rule with severity=Critical, reload the page, and still see
Critical — no silent drops." Verified end-to-end: frontend sends
`severity: "Critical"`, handler validates, service persists, DB stores,
GET returns, React renders the correct badge.
Seed data
---------
`migrations/seed.sql`: four demo rules now have differentiated severities
— `pr-require-owner` → Warning, `pr-allowed-environments` → Error,
`pr-max-certificate-lifetime` → Critical, `pr-min-renewal-window` →
Warning. The user called out that seeding all four at the same severity
makes the feature look decorative; differentiation demonstrates the
column carries real signal.
## Integration test fix (side effect of D-006)
`internal/integration/e2e_test.go::TestCrossResourceWorkflow/CreatePolicy`
was sending `"severity": "High"` — a value from the pre-audit severity
vocabulary that the new `ValidatePolicySeverity` correctly rejects with
400. Changed to `"Error"` (closest semantic match in the new TitleCase
allowlist). Only severity reference in the integration/ directory;
verified via grep.
## Out of scope, logged for follow-up (d/D-008)
Three policy-engine drift issues orthogonal to D-005 + D-006, explicitly
deferred per direction:
1. `migrations/seed.sql` policy_rules INSERTs use lowercase TYPE values
(`'ownership'`, `'environment'`, `'lifetime'`, `'renewal_window'`).
These are load-bearing on `internal/service/policy.go::evaluateRule`'s
`switch rule.Type` (which also uses the lowercase strings). Migrating
requires coordinated changes across seed + evaluation engine.
2. `migrations/seed_demo.sql:482-483` contains lowercase `'critical'`
severity — will now fail the new CHECK constraint. Separate fix.
3. `evaluateRule` hardcodes `Severity: domain.PolicySeverityWarning` on
emitted violations and ignores the configured `rule.Config`. The new
severity column is read correctly on the CRUD path but not yet
consulted during evaluation.
## Verification
Backend:
- `go build ./...` — clean
- `go vet ./...` — clean
- `go test -short ./...` — all packages green, including
`internal/service` (policy service), `internal/api/handler` (policy +
MCP handler tests), `internal/integration` (e2e_test.go after fix),
`internal/domain`, `internal/repository/postgres`.
Frontend:
- `tsc --noEmit` — clean
- `vitest run` — 223/223 passing (4 new assertions in types.test.ts)
- `vite build` — clean (only the pre-existing chunk-size warning)
This commit is contained in:
@@ -3480,6 +3480,9 @@ components:
|
|||||||
description: Policy-specific configuration (varies by type)
|
description: Policy-specific configuration (varies by type)
|
||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
severity:
|
||||||
|
$ref: "#/components/schemas/PolicySeverity"
|
||||||
|
description: Severity level applied to violations of this rule. Defaults to Warning on create when omitted.
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
|||||||
@@ -127,6 +127,17 @@ func (h PolicyHandler) CreatePolicy(w http.ResponseWriter, r *http.Request) {
|
|||||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Severity is optional on create; default matches the DB default.
|
||||||
|
// Any explicit value must pass the TitleCase allowlist; the DB CHECK
|
||||||
|
// constraint enforces the same set, but catching it here gives a 400
|
||||||
|
// with a clear message instead of a 500 on constraint violation.
|
||||||
|
if policy.Severity == "" {
|
||||||
|
policy.Severity = domain.PolicySeverityWarning
|
||||||
|
}
|
||||||
|
if err := ValidatePolicySeverity(policy.Severity); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
created, err := h.svc.CreatePolicy(r.Context(), policy)
|
created, err := h.svc.CreatePolicy(r.Context(), policy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -174,6 +185,12 @@ func (h PolicyHandler) UpdatePolicy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if policy.Severity != "" {
|
||||||
|
if err := ValidatePolicySeverity(policy.Severity); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updated, err := h.svc.UpdatePolicy(r.Context(), id, policy)
|
updated, err := h.svc.UpdatePolicy(r.Context(), id, policy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type PolicyRule struct {
|
|||||||
Type PolicyType `json:"type"`
|
Type PolicyType `json:"type"`
|
||||||
Config json.RawMessage `json:"config"`
|
Config json.RawMessage `json:"config"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
Severity PolicySeverity `json:"severity"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ func TestCrossResourceWorkflow(t *testing.T) {
|
|||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"name": "Allowed Domains Policy",
|
"name": "Allowed Domains Policy",
|
||||||
"type": "AllowedDomains",
|
"type": "AllowedDomains",
|
||||||
"severity": "High",
|
"severity": "Error",
|
||||||
"config": json.RawMessage(`{"domains": ["example.com", "*.example.com"]}`),
|
"config": json.RawMessage(`{"domains": ["example.com", "*.example.com"]}`),
|
||||||
"description": "Restrict issuance to example.com domains",
|
"description": "Restrict issuance to example.com domains",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -610,7 +610,7 @@ func registerPolicyTools(s *gomcp.Server, c *Client) {
|
|||||||
|
|
||||||
gomcp.AddTool(s, &gomcp.Tool{
|
gomcp.AddTool(s, &gomcp.Tool{
|
||||||
Name: "certctl_create_policy",
|
Name: "certctl_create_policy",
|
||||||
Description: "Create a new policy rule. Requires name and type.",
|
Description: "Create a new policy rule. Requires name and type. Optional severity (Warning, Error, Critical) defaults to Warning.",
|
||||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input CreatePolicyInput) (*gomcp.CallToolResult, any, error) {
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input CreatePolicyInput) (*gomcp.CallToolResult, any, error) {
|
||||||
data, err := c.Post("/api/v1/policies", input)
|
data, err := c.Post("/api/v1/policies", input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -621,7 +621,7 @@ func registerPolicyTools(s *gomcp.Server, c *Client) {
|
|||||||
|
|
||||||
gomcp.AddTool(s, &gomcp.Tool{
|
gomcp.AddTool(s, &gomcp.Tool{
|
||||||
Name: "certctl_update_policy",
|
Name: "certctl_update_policy",
|
||||||
Description: "Update a policy rule's name, type, configuration, or enabled status.",
|
Description: "Update a policy rule's name, type, configuration, enabled status, or severity.",
|
||||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdatePolicyInput) (*gomcp.CallToolResult, any, error) {
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdatePolicyInput) (*gomcp.CallToolResult, any, error) {
|
||||||
data, err := c.Put("/api/v1/policies/"+input.ID, input)
|
data, err := c.Put("/api/v1/policies/"+input.ID, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ type CreatePolicyInput struct {
|
|||||||
Type string `json:"type" jsonschema:"Policy type: AllowedIssuers, AllowedDomains, RequiredMetadata, AllowedEnvironments, RenewalLeadTime"`
|
Type string `json:"type" jsonschema:"Policy type: AllowedIssuers, AllowedDomains, RequiredMetadata, AllowedEnvironments, RenewalLeadTime"`
|
||||||
Config interface{} `json:"config,omitempty" jsonschema:"Policy-specific configuration"`
|
Config interface{} `json:"config,omitempty" jsonschema:"Policy-specific configuration"`
|
||||||
Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the policy is enabled"`
|
Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the policy is enabled"`
|
||||||
|
Severity string `json:"severity,omitempty" jsonschema:"Violation severity: Warning, Error, or Critical (default: Warning)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdatePolicyInput struct {
|
type UpdatePolicyInput struct {
|
||||||
@@ -181,6 +182,7 @@ type UpdatePolicyInput struct {
|
|||||||
Type string `json:"type,omitempty" jsonschema:"Policy type"`
|
Type string `json:"type,omitempty" jsonschema:"Policy type"`
|
||||||
Config interface{} `json:"config,omitempty" jsonschema:"Policy-specific configuration"`
|
Config interface{} `json:"config,omitempty" jsonschema:"Policy-specific configuration"`
|
||||||
Enabled *bool `json:"enabled,omitempty" jsonschema:"Whether the policy is enabled"`
|
Enabled *bool `json:"enabled,omitempty" jsonschema:"Whether the policy is enabled"`
|
||||||
|
Severity string `json:"severity,omitempty" jsonschema:"Violation severity: Warning, Error, or Critical"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListViolationsInput struct {
|
type ListViolationsInput struct {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func NewPolicyRepository(db *sql.DB) *PolicyRepository {
|
|||||||
// ListRules returns all policy rules
|
// ListRules returns all policy rules
|
||||||
func (r *PolicyRepository) ListRules(ctx context.Context) ([]*domain.PolicyRule, error) {
|
func (r *PolicyRepository) ListRules(ctx context.Context) ([]*domain.PolicyRule, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, name, type, config, enabled, created_at, updated_at
|
SELECT id, name, type, config, enabled, severity, created_at, updated_at
|
||||||
FROM policy_rules
|
FROM policy_rules
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`)
|
`)
|
||||||
@@ -38,7 +38,7 @@ func (r *PolicyRepository) ListRules(ctx context.Context) ([]*domain.PolicyRule,
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var rule domain.PolicyRule
|
var rule domain.PolicyRule
|
||||||
if err := rows.Scan(&rule.ID, &rule.Name, &rule.Type, &rule.Config,
|
if err := rows.Scan(&rule.ID, &rule.Name, &rule.Type, &rule.Config,
|
||||||
&rule.Enabled, &rule.CreatedAt, &rule.UpdatedAt); err != nil {
|
&rule.Enabled, &rule.Severity, &rule.CreatedAt, &rule.UpdatedAt); err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan policy rule: %w", err)
|
return nil, fmt.Errorf("failed to scan policy rule: %w", err)
|
||||||
}
|
}
|
||||||
rules = append(rules, &rule)
|
rules = append(rules, &rule)
|
||||||
@@ -55,11 +55,11 @@ func (r *PolicyRepository) ListRules(ctx context.Context) ([]*domain.PolicyRule,
|
|||||||
func (r *PolicyRepository) GetRule(ctx context.Context, id string) (*domain.PolicyRule, error) {
|
func (r *PolicyRepository) GetRule(ctx context.Context, id string) (*domain.PolicyRule, error) {
|
||||||
var rule domain.PolicyRule
|
var rule domain.PolicyRule
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
SELECT id, name, type, config, enabled, created_at, updated_at
|
SELECT id, name, type, config, enabled, severity, created_at, updated_at
|
||||||
FROM policy_rules
|
FROM policy_rules
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, id).Scan(&rule.ID, &rule.Name, &rule.Type, &rule.Config,
|
`, id).Scan(&rule.ID, &rule.Name, &rule.Type, &rule.Config,
|
||||||
&rule.Enabled, &rule.CreatedAt, &rule.UpdatedAt)
|
&rule.Enabled, &rule.Severity, &rule.CreatedAt, &rule.UpdatedAt)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -78,11 +78,11 @@ func (r *PolicyRepository) CreateRule(ctx context.Context, rule *domain.PolicyRu
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
INSERT INTO policy_rules (id, name, type, config, enabled, created_at, updated_at)
|
INSERT INTO policy_rules (id, name, type, config, enabled, severity, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, rule.ID, rule.Name, rule.Type, rule.Config, rule.Enabled,
|
`, rule.ID, rule.Name, rule.Type, rule.Config, rule.Enabled,
|
||||||
rule.CreatedAt, rule.UpdatedAt).Scan(&rule.ID)
|
rule.Severity, rule.CreatedAt, rule.UpdatedAt).Scan(&rule.ID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create policy rule: %w", err)
|
return fmt.Errorf("failed to create policy rule: %w", err)
|
||||||
@@ -99,9 +99,10 @@ func (r *PolicyRepository) UpdateRule(ctx context.Context, rule *domain.PolicyRu
|
|||||||
type = $2,
|
type = $2,
|
||||||
config = $3,
|
config = $3,
|
||||||
enabled = $4,
|
enabled = $4,
|
||||||
updated_at = $5
|
severity = $5,
|
||||||
WHERE id = $6
|
updated_at = $6
|
||||||
`, rule.Name, rule.Type, rule.Config, rule.Enabled, rule.UpdatedAt, rule.ID)
|
WHERE id = $7
|
||||||
|
`, rule.Name, rule.Type, rule.Config, rule.Enabled, rule.Severity, rule.UpdatedAt, rule.ID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to update policy rule: %w", err)
|
return fmt.Errorf("failed to update policy rule: %w", err)
|
||||||
|
|||||||
@@ -288,6 +288,20 @@ func (s *PolicyService) UpdatePolicy(ctx context.Context, id string, policy doma
|
|||||||
policy.ID = id
|
policy.ID = id
|
||||||
policy.UpdatedAt = time.Now()
|
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 {
|
if err := s.policyRepo.UpdateRule(ctx, &policy); err != nil {
|
||||||
return nil, fmt.Errorf("failed to update policy: %w", err)
|
return nil, fmt.Errorf("failed to update policy: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Rollback migration 000013: remove per-rule severity.
|
||||||
|
--
|
||||||
|
-- DROP COLUMN removes the column, its CHECK constraint, and the default in
|
||||||
|
-- one statement. Any downstream code still referencing severity after
|
||||||
|
-- rollback will fail at query time — that's intentional, since running this
|
||||||
|
-- rollback implies severity as a concept is being abandoned.
|
||||||
|
|
||||||
|
ALTER TABLE policy_rules DROP COLUMN IF EXISTS severity;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Migration 000013: Per-Rule Severity on policy_rules
|
||||||
|
--
|
||||||
|
-- Prior to this migration, PolicyRule had no severity column. The TypeScript
|
||||||
|
-- frontend (PoliciesPage.tsx) sent a `severity` field on create/update, but
|
||||||
|
-- Go's json.Decoder silently dropped it (no matching struct field) and the
|
||||||
|
-- value never reached PostgreSQL. Reloading the page always showed severity
|
||||||
|
-- reverting to a default — the classic "silent drop" bug.
|
||||||
|
--
|
||||||
|
-- This migration adds severity as a first-class column on policy_rules.
|
||||||
|
-- Default `'Warning'` covers pre-existing rows; the CHECK constraint gives
|
||||||
|
-- defense-in-depth against casing drift (the application-layer validator in
|
||||||
|
-- internal/api/handler/validation.go already enforces the TitleCase allowlist,
|
||||||
|
-- but the DB should reject a bypassed write too).
|
||||||
|
--
|
||||||
|
-- No index: three-value column on a table that stays in the low thousands of
|
||||||
|
-- rows. The planner will seq-scan regardless; write cost without read benefit.
|
||||||
|
-- If measurements later justify it, add the index then.
|
||||||
|
--
|
||||||
|
-- PG 11+ makes ADD COLUMN with a literal DEFAULT a metadata-only operation
|
||||||
|
-- (no table rewrite), so this is safe to run on a live server.
|
||||||
|
|
||||||
|
ALTER TABLE policy_rules
|
||||||
|
ADD COLUMN IF NOT EXISTS severity VARCHAR(50) NOT NULL DEFAULT 'Warning'
|
||||||
|
CHECK (severity IN ('Warning', 'Error', 'Critical'));
|
||||||
+18
-8
@@ -13,41 +13,51 @@ VALUES (
|
|||||||
) ON CONFLICT (id) DO NOTHING;
|
) ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- Policy rules: Require owner assignment
|
-- Policy rules: Require owner assignment
|
||||||
INSERT INTO policy_rules (id, name, type, config, enabled)
|
-- Severity differentiated per rule to demonstrate the field means something
|
||||||
|
-- (D-006). The backend CHECK constraint (migration 000013) enforces the
|
||||||
|
-- TitleCase allowlist Warning/Error/Critical. Type-value drift
|
||||||
|
-- (ownership/environment/lifetime/renewal_window vs. the engine's TitleCase
|
||||||
|
-- canonicals) is tracked separately in d/D-008 and intentionally left
|
||||||
|
-- unchanged in this commit.
|
||||||
|
INSERT INTO policy_rules (id, name, type, config, enabled, severity)
|
||||||
VALUES (
|
VALUES (
|
||||||
'pr-require-owner',
|
'pr-require-owner',
|
||||||
'require-owner',
|
'require-owner',
|
||||||
'ownership',
|
'ownership',
|
||||||
'{"requirement": "owner_id must be set"}'::jsonb,
|
'{"requirement": "owner_id must be set"}'::jsonb,
|
||||||
true
|
true,
|
||||||
|
'Warning'
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
) ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- Policy rules: Allowed environments
|
-- Policy rules: Allowed environments
|
||||||
INSERT INTO policy_rules (id, name, type, config, enabled)
|
INSERT INTO policy_rules (id, name, type, config, enabled, severity)
|
||||||
VALUES (
|
VALUES (
|
||||||
'pr-allowed-environments',
|
'pr-allowed-environments',
|
||||||
'allowed-environments',
|
'allowed-environments',
|
||||||
'environment',
|
'environment',
|
||||||
'{"allowed": ["production", "staging", "development"]}'::jsonb,
|
'{"allowed": ["production", "staging", "development"]}'::jsonb,
|
||||||
true
|
true,
|
||||||
|
'Error'
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
) ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- Policy rules: Maximum certificate lifetime
|
-- Policy rules: Maximum certificate lifetime
|
||||||
INSERT INTO policy_rules (id, name, type, config, enabled)
|
INSERT INTO policy_rules (id, name, type, config, enabled, severity)
|
||||||
VALUES (
|
VALUES (
|
||||||
'pr-max-certificate-lifetime',
|
'pr-max-certificate-lifetime',
|
||||||
'max-certificate-lifetime',
|
'max-certificate-lifetime',
|
||||||
'lifetime',
|
'lifetime',
|
||||||
'{"max_days": 90}'::jsonb,
|
'{"max_days": 90}'::jsonb,
|
||||||
true
|
true,
|
||||||
|
'Critical'
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
) ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- Policy rules: Minimum renewal window
|
-- Policy rules: Minimum renewal window
|
||||||
INSERT INTO policy_rules (id, name, type, config, enabled)
|
INSERT INTO policy_rules (id, name, type, config, enabled, severity)
|
||||||
VALUES (
|
VALUES (
|
||||||
'pr-min-renewal-window',
|
'pr-min-renewal-window',
|
||||||
'min-renewal-window',
|
'min-renewal-window',
|
||||||
'renewal_window',
|
'renewal_window',
|
||||||
'{"min_days": 14}'::jsonb,
|
'{"min_days": 14}'::jsonb,
|
||||||
true
|
true,
|
||||||
|
'Warning'
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
) ON CONFLICT (id) DO NOTHING;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { POLICY_TYPES, POLICY_SEVERITIES } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for the policy enum tuples.
|
||||||
|
*
|
||||||
|
* These tuples are the GUI's source of truth for the policy type and severity
|
||||||
|
* dropdowns. They MUST stay in lockstep with the backend enum values:
|
||||||
|
* - internal/domain/policy.go defines the PolicyType / PolicySeverity consts
|
||||||
|
* - internal/api/handler/validators.go rejects anything outside the allowlist
|
||||||
|
* - migration 000013 enforces the severity allowlist at the DB level via CHECK
|
||||||
|
*
|
||||||
|
* Audit history (D-005, D-006):
|
||||||
|
* - The GUI previously sent lowercase values (e.g. 'key_algorithm',
|
||||||
|
* 'ownership'), which the backend validator rejected with a 400. Every
|
||||||
|
* attempt to create a policy from the "+ New Policy" button silently
|
||||||
|
* failed until the modal was closed.
|
||||||
|
* - The severity dropdown carried a four-value `low/medium/high/critical`
|
||||||
|
* tuple that shared zero values with the backend's
|
||||||
|
* `Warning/Error/Critical` — the `medium` option has no backend analog
|
||||||
|
* and is removed.
|
||||||
|
*
|
||||||
|
* If these tests fail because a backend enum changed, DO NOT update the
|
||||||
|
* expected arrays without also updating the backend consts and the migration.
|
||||||
|
* Frontend/backend drift on these tuples is precisely what this regression
|
||||||
|
* guards against.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('POLICY_TYPES', () => {
|
||||||
|
it('matches the backend PolicyType TitleCase allowlist exactly', () => {
|
||||||
|
expect(POLICY_TYPES).toEqual([
|
||||||
|
'AllowedIssuers',
|
||||||
|
'AllowedDomains',
|
||||||
|
'RequiredMetadata',
|
||||||
|
'AllowedEnvironments',
|
||||||
|
'RenewalLeadTime',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no duplicate entries', () => {
|
||||||
|
expect(new Set(POLICY_TYPES).size).toBe(POLICY_TYPES.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POLICY_SEVERITIES', () => {
|
||||||
|
it('matches the backend PolicySeverity TitleCase allowlist exactly', () => {
|
||||||
|
expect(POLICY_SEVERITIES).toEqual(['Warning', 'Error', 'Critical']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no duplicate entries', () => {
|
||||||
|
expect(new Set(POLICY_SEVERITIES).size).toBe(POLICY_SEVERITIES.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include the removed pre-fix `medium` value', () => {
|
||||||
|
// Explicit negative assertion. Pre-fix the GUI offered four severities
|
||||||
|
// (low/medium/high/critical); `medium` never had a backend analog.
|
||||||
|
expect(POLICY_SEVERITIES as readonly string[]).not.toContain('medium');
|
||||||
|
});
|
||||||
|
});
|
||||||
+29
-3
@@ -112,11 +112,37 @@ export interface AuditEvent {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Policy rule type enum — pinned to the backend's TitleCase constants in
|
||||||
|
* internal/domain/policy.go. Historical note (D-005): the GUI previously sent
|
||||||
|
* lowercase values (`ownership`, `environment`, etc.) that the handler's
|
||||||
|
* ValidatePolicyType rejected with a 400. These tuples are the canonical
|
||||||
|
* source of truth for the dropdown options; the regression test in
|
||||||
|
* types.test.ts pins them so future drift is caught at CI time.
|
||||||
|
*/
|
||||||
|
export const POLICY_TYPES = [
|
||||||
|
'AllowedIssuers',
|
||||||
|
'AllowedDomains',
|
||||||
|
'RequiredMetadata',
|
||||||
|
'AllowedEnvironments',
|
||||||
|
'RenewalLeadTime',
|
||||||
|
] as const;
|
||||||
|
export type PolicyType = (typeof POLICY_TYPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Policy severity enum — pinned to the backend's PolicySeverity constants.
|
||||||
|
* The backend CHECK constraint on policy_rules.severity enforces the same
|
||||||
|
* allowlist (migration 000013). The 4-value `medium` option that used to
|
||||||
|
* appear in the GUI was never a valid backend value and has been removed.
|
||||||
|
*/
|
||||||
|
export const POLICY_SEVERITIES = ['Warning', 'Error', 'Critical'] as const;
|
||||||
|
export type PolicySeverity = (typeof POLICY_SEVERITIES)[number];
|
||||||
|
|
||||||
export interface PolicyRule {
|
export interface PolicyRule {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: PolicyType;
|
||||||
severity: string;
|
severity: PolicySeverity;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -127,7 +153,7 @@ export interface PolicyViolation {
|
|||||||
id: string;
|
id: string;
|
||||||
rule_id: string;
|
rule_id: string;
|
||||||
certificate_id: string;
|
certificate_id: string;
|
||||||
severity: string;
|
severity: PolicySeverity;
|
||||||
message: string;
|
message: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,40 @@ import DataTable from '../components/DataTable';
|
|||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
import { formatDateTime } from '../api/utils';
|
import { formatDateTime } from '../api/utils';
|
||||||
import type { PolicyRule } from '../api/types';
|
import {
|
||||||
|
POLICY_TYPES,
|
||||||
|
POLICY_SEVERITIES,
|
||||||
|
type PolicyRule,
|
||||||
|
type PolicyType,
|
||||||
|
type PolicySeverity,
|
||||||
|
} from '../api/types';
|
||||||
|
|
||||||
const severityStyles: Record<string, string> = {
|
/**
|
||||||
low: 'badge-info',
|
* Severity → badge style. Keyed on the backend's TitleCase PolicySeverity
|
||||||
medium: 'badge-warning',
|
* enum values (D-006). The pre-fix map keyed on `low`/`medium`/`high`/`critical`
|
||||||
high: 'badge-danger',
|
* which never matched the backend's `Warning`/`Error`/`Critical`, so every
|
||||||
critical: 'badge-danger',
|
* existing rule fell through to the `badge-neutral` default.
|
||||||
|
*/
|
||||||
|
const severityStyles: Record<PolicySeverity, string> = {
|
||||||
|
Warning: 'badge-warning',
|
||||||
|
Error: 'badge-danger',
|
||||||
|
Critical: 'badge-danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
const severityDots: Record<string, string> = {
|
const severityDots: Record<PolicySeverity, string> = {
|
||||||
low: 'bg-emerald-500',
|
Warning: 'bg-amber-500',
|
||||||
medium: 'bg-amber-500',
|
Error: 'bg-orange-500',
|
||||||
high: 'bg-orange-500',
|
Critical: 'bg-red-500',
|
||||||
critical: 'bg-red-500',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert TitleCase enum value to a human-readable label for display.
|
||||||
|
* "AllowedIssuers" → "Allowed Issuers"
|
||||||
|
*/
|
||||||
|
function humanize(s: string): string {
|
||||||
|
return s.replace(/([A-Z])/g, ' $1').trim();
|
||||||
|
}
|
||||||
|
|
||||||
interface CreatePolicyModalProps {
|
interface CreatePolicyModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -32,8 +50,8 @@ interface CreatePolicyModalProps {
|
|||||||
|
|
||||||
function CreatePolicyModal({ isOpen, onClose, onSuccess, isLoading, error }: CreatePolicyModalProps) {
|
function CreatePolicyModal({ isOpen, onClose, onSuccess, isLoading, error }: CreatePolicyModalProps) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [type, setType] = useState('key_algorithm');
|
const [type, setType] = useState<PolicyType>(POLICY_TYPES[0]);
|
||||||
const [severity, setSeverity] = useState('medium');
|
const [severity, setSeverity] = useState<PolicySeverity>('Warning');
|
||||||
const [configStr, setConfigStr] = useState('{}');
|
const [configStr, setConfigStr] = useState('{}');
|
||||||
const [enabled, setEnabled] = useState(true);
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
|
||||||
@@ -43,8 +61,8 @@ function CreatePolicyModal({ isOpen, onClose, onSuccess, isLoading, error }: Cre
|
|||||||
const config = JSON.parse(configStr);
|
const config = JSON.parse(configStr);
|
||||||
await createPolicy({ name: name.trim(), type, severity, config, enabled });
|
await createPolicy({ name: name.trim(), type, severity, config, enabled });
|
||||||
setName('');
|
setName('');
|
||||||
setType('key_algorithm');
|
setType(POLICY_TYPES[0]);
|
||||||
setSeverity('medium');
|
setSeverity('Warning');
|
||||||
setConfigStr('{}');
|
setConfigStr('{}');
|
||||||
setEnabled(true);
|
setEnabled(true);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
@@ -72,27 +90,24 @@ function CreatePolicyModal({ isOpen, onClose, onSuccess, isLoading, error }: Cre
|
|||||||
<label className="block text-sm font-medium text-ink mb-1">Type *</label>
|
<label className="block text-sm font-medium text-ink mb-1">Type *</label>
|
||||||
<select
|
<select
|
||||||
value={type}
|
value={type}
|
||||||
onChange={e => setType(e.target.value)}
|
onChange={e => setType(e.target.value as PolicyType)}
|
||||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||||
>
|
>
|
||||||
<option value="key_algorithm">Key Algorithm</option>
|
{POLICY_TYPES.map(t => (
|
||||||
<option value="cert_lifetime">Certificate Lifetime</option>
|
<option key={t} value={t}>{humanize(t)}</option>
|
||||||
<option value="san_pattern">SAN Pattern</option>
|
))}
|
||||||
<option value="key_usage">Key Usage</option>
|
|
||||||
<option value="revocation_check">Revocation Check</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-ink mb-1">Severity *</label>
|
<label className="block text-sm font-medium text-ink mb-1">Severity *</label>
|
||||||
<select
|
<select
|
||||||
value={severity}
|
value={severity}
|
||||||
onChange={e => setSeverity(e.target.value)}
|
onChange={e => setSeverity(e.target.value as PolicySeverity)}
|
||||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||||
>
|
>
|
||||||
<option value="low">Low</option>
|
{POLICY_SEVERITIES.map(s => (
|
||||||
<option value="medium">Medium</option>
|
<option key={s} value={s}>{s}</option>
|
||||||
<option value="high">High</option>
|
))}
|
||||||
<option value="critical">Critical</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -182,7 +197,7 @@ export default function PoliciesPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: 'type', label: 'Type', render: (p) => <span className="text-sm text-ink">{p.type.replace(/_/g, ' ')}</span> },
|
{ key: 'type', label: 'Type', render: (p) => <span className="text-sm text-ink">{humanize(p.type)}</span> },
|
||||||
{
|
{
|
||||||
key: 'severity',
|
key: 'severity',
|
||||||
label: 'Severity',
|
label: 'Severity',
|
||||||
@@ -248,8 +263,8 @@ export default function PoliciesPage() {
|
|||||||
</div>
|
</div>
|
||||||
{Object.entries(bySeverity).map(([sev, count]) => (
|
{Object.entries(bySeverity).map(([sev, count]) => (
|
||||||
<div key={sev} className="flex items-center gap-1.5">
|
<div key={sev} className="flex items-center gap-1.5">
|
||||||
<div className={`w-2 h-2 rounded-full ${severityDots[sev] || 'bg-slate-400'}`} />
|
<div className={`w-2 h-2 rounded-full ${severityDots[sev as PolicySeverity] || 'bg-slate-400'}`} />
|
||||||
<span className="text-xs text-ink capitalize">{sev}</span>
|
<span className="text-xs text-ink">{sev}</span>
|
||||||
<span className="text-xs text-ink-faint">{count}</span>
|
<span className="text-xs text-ink-faint">{count}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user