Files
breakpilot-lehrer/edu-search-service/internal/policy/store.go
Benjamin Admin 9ba420fa91
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 42s
CI / test-go-edu-search (push) Successful in 34s
CI / test-python-klausur (push) Failing after 2m51s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 29s
Fix: Remove broken getKlausurApiUrl and clean up empty lines
sed replacement left orphaned hostname references in story page
and empty lines in getApiBase functions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 16:02:04 +02:00

360 lines
9.6 KiB
Go

package policy
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store provides database operations for the policy system.
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new Store instance.
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// =============================================================================
// SOURCE POLICIES
// =============================================================================
// CreatePolicy creates a new source policy.
func (s *Store) CreatePolicy(ctx context.Context, req *CreateSourcePolicyRequest) (*SourcePolicy, error) {
policy := &SourcePolicy{
ID: uuid.New(),
Version: 1,
Name: req.Name,
Description: req.Description,
Bundesland: req.Bundesland,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
query := `
INSERT INTO source_policies (id, version, name, description, bundesland, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, version, name, description, bundesland, is_active, created_at, updated_at`
err := s.pool.QueryRow(ctx, query,
policy.ID, policy.Version, policy.Name, policy.Description,
policy.Bundesland, policy.IsActive, policy.CreatedAt, policy.UpdatedAt,
).Scan(
&policy.ID, &policy.Version, &policy.Name, &policy.Description,
&policy.Bundesland, &policy.IsActive, &policy.CreatedAt, &policy.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to create policy: %w", err)
}
return policy, nil
}
// GetPolicy retrieves a policy by ID.
func (s *Store) GetPolicy(ctx context.Context, id uuid.UUID) (*SourcePolicy, error) {
query := `
SELECT id, version, name, description, bundesland, is_active,
created_at, updated_at, approved_by, approved_at
FROM source_policies
WHERE id = $1`
policy := &SourcePolicy{}
err := s.pool.QueryRow(ctx, query, id).Scan(
&policy.ID, &policy.Version, &policy.Name, &policy.Description,
&policy.Bundesland, &policy.IsActive, &policy.CreatedAt, &policy.UpdatedAt,
&policy.ApprovedBy, &policy.ApprovedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get policy: %w", err)
}
return policy, nil
}
// ListPolicies retrieves policies with optional filters.
func (s *Store) ListPolicies(ctx context.Context, filter *PolicyListFilter) ([]SourcePolicy, int, error) {
baseQuery := `FROM source_policies WHERE 1=1`
args := []interface{}{}
argCount := 0
if filter.Bundesland != nil {
argCount++
baseQuery += fmt.Sprintf(" AND bundesland = $%d", argCount)
args = append(args, *filter.Bundesland)
}
if filter.IsActive != nil {
argCount++
baseQuery += fmt.Sprintf(" AND is_active = $%d", argCount)
args = append(args, *filter.IsActive)
}
// Count query
var total int
countQuery := "SELECT COUNT(*) " + baseQuery
err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total)
if err != nil {
return nil, 0, fmt.Errorf("failed to count policies: %w", err)
}
// Data query with pagination
dataQuery := `SELECT id, version, name, description, bundesland, is_active,
created_at, updated_at, approved_by, approved_at ` + baseQuery +
` ORDER BY created_at DESC`
if filter.Limit > 0 {
argCount++
dataQuery += fmt.Sprintf(" LIMIT $%d", argCount)
args = append(args, filter.Limit)
}
if filter.Offset > 0 {
argCount++
dataQuery += fmt.Sprintf(" OFFSET $%d", argCount)
args = append(args, filter.Offset)
}
rows, err := s.pool.Query(ctx, dataQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("failed to list policies: %w", err)
}
defer rows.Close()
policies := []SourcePolicy{}
for rows.Next() {
var p SourcePolicy
err := rows.Scan(
&p.ID, &p.Version, &p.Name, &p.Description,
&p.Bundesland, &p.IsActive, &p.CreatedAt, &p.UpdatedAt,
&p.ApprovedBy, &p.ApprovedAt,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to scan policy: %w", err)
}
policies = append(policies, p)
}
return policies, total, nil
}
// UpdatePolicy updates an existing policy.
func (s *Store) UpdatePolicy(ctx context.Context, id uuid.UUID, req *UpdateSourcePolicyRequest) (*SourcePolicy, error) {
policy, err := s.GetPolicy(ctx, id)
if err != nil {
return nil, err
}
if policy == nil {
return nil, fmt.Errorf("policy not found")
}
if req.Name != nil {
policy.Name = *req.Name
}
if req.Description != nil {
policy.Description = req.Description
}
if req.Bundesland != nil {
policy.Bundesland = req.Bundesland
}
if req.IsActive != nil {
policy.IsActive = *req.IsActive
}
policy.Version++
policy.UpdatedAt = time.Now()
query := `
UPDATE source_policies
SET version = $2, name = $3, description = $4, bundesland = $5,
is_active = $6, updated_at = $7
WHERE id = $1
RETURNING id, version, name, description, bundesland, is_active, created_at, updated_at`
err = s.pool.QueryRow(ctx, query,
id, policy.Version, policy.Name, policy.Description,
policy.Bundesland, policy.IsActive, policy.UpdatedAt,
).Scan(
&policy.ID, &policy.Version, &policy.Name, &policy.Description,
&policy.Bundesland, &policy.IsActive, &policy.CreatedAt, &policy.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to update policy: %w", err)
}
return policy, nil
}
// DeletePolicy deletes a policy by ID (soft delete via is_active = false).
func (s *Store) DeletePolicy(ctx context.Context, id uuid.UUID) error {
query := `UPDATE source_policies SET is_active = false, updated_at = $2 WHERE id = $1`
_, err := s.pool.Exec(ctx, query, id, time.Now())
if err != nil {
return fmt.Errorf("failed to delete policy: %w", err)
}
return nil
}
// =============================================================================
// PII RULES
// =============================================================================
// CreatePIIRule creates a new PII rule.
func (s *Store) CreatePIIRule(ctx context.Context, req *CreatePIIRuleRequest) (*PIIRule, error) {
severity := PIISeverityBlock
if req.Severity != "" {
severity = req.Severity
}
rule := &PIIRule{
ID: uuid.New(),
Name: req.Name,
Description: req.Description,
RuleType: req.RuleType,
Pattern: req.Pattern,
Severity: severity,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
query := `
INSERT INTO pii_rules (id, name, description, rule_type, pattern, severity, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`
err := s.pool.QueryRow(ctx, query,
rule.ID, rule.Name, rule.Description, rule.RuleType, rule.Pattern,
rule.Severity, rule.IsActive, rule.CreatedAt, rule.UpdatedAt,
).Scan(&rule.ID)
if err != nil {
return nil, fmt.Errorf("failed to create PII rule: %w", err)
}
return rule, nil
}
// GetPIIRule retrieves a PII rule by ID.
func (s *Store) GetPIIRule(ctx context.Context, id uuid.UUID) (*PIIRule, error) {
query := `
SELECT id, name, description, rule_type, pattern, severity, is_active, created_at, updated_at
FROM pii_rules
WHERE id = $1`
rule := &PIIRule{}
err := s.pool.QueryRow(ctx, query, id).Scan(
&rule.ID, &rule.Name, &rule.Description, &rule.RuleType, &rule.Pattern,
&rule.Severity, &rule.IsActive, &rule.CreatedAt, &rule.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get PII rule: %w", err)
}
return rule, nil
}
// ListPIIRules retrieves all PII rules.
func (s *Store) ListPIIRules(ctx context.Context, activeOnly bool) ([]PIIRule, error) {
query := `
SELECT id, name, description, rule_type, pattern, severity, is_active, created_at, updated_at
FROM pii_rules`
if activeOnly {
query += ` WHERE is_active = true`
}
query += ` ORDER BY severity DESC, name`
rows, err := s.pool.Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list PII rules: %w", err)
}
defer rows.Close()
rules := []PIIRule{}
for rows.Next() {
var r PIIRule
err := rows.Scan(
&r.ID, &r.Name, &r.Description, &r.RuleType, &r.Pattern,
&r.Severity, &r.IsActive, &r.CreatedAt, &r.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan PII rule: %w", err)
}
rules = append(rules, r)
}
return rules, nil
}
// UpdatePIIRule updates an existing PII rule.
func (s *Store) UpdatePIIRule(ctx context.Context, id uuid.UUID, req *UpdatePIIRuleRequest) (*PIIRule, error) {
rule, err := s.GetPIIRule(ctx, id)
if err != nil {
return nil, err
}
if rule == nil {
return nil, fmt.Errorf("PII rule not found")
}
if req.Name != nil {
rule.Name = *req.Name
}
if req.Description != nil {
rule.Description = req.Description
}
if req.RuleType != nil {
rule.RuleType = *req.RuleType
}
if req.Pattern != nil {
rule.Pattern = *req.Pattern
}
if req.Severity != nil {
rule.Severity = *req.Severity
}
if req.IsActive != nil {
rule.IsActive = *req.IsActive
}
rule.UpdatedAt = time.Now()
query := `
UPDATE pii_rules
SET name = $2, description = $3, rule_type = $4, pattern = $5,
severity = $6, is_active = $7, updated_at = $8
WHERE id = $1`
_, err = s.pool.Exec(ctx, query,
id, rule.Name, rule.Description, rule.RuleType, rule.Pattern,
rule.Severity, rule.IsActive, rule.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to update PII rule: %w", err)
}
return rule, nil
}
// DeletePIIRule deletes a PII rule by ID.
func (s *Store) DeletePIIRule(ctx context.Context, id uuid.UUID) error {
query := `DELETE FROM pii_rules WHERE id = $1`
_, err := s.pool.Exec(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to delete PII rule: %w", err)
}
return nil
}