diff --git a/src/cmd/server/main.go b/src/cmd/server/main.go index 725f3d9..d9e37cd 100644 --- a/src/cmd/server/main.go +++ b/src/cmd/server/main.go @@ -18,6 +18,7 @@ import ( "authelia-api/internal/bootstrap" "authelia-api/internal/config" "authelia-api/internal/database" + "authelia-api/internal/policies" "authelia-api/internal/smtp" "authelia-api/internal/sync" ) @@ -170,10 +171,12 @@ func (a *Application) Start() error { // Create API handlers usersHandler := api.NewUsersHandler(a.db, a.config.AutheliaBinaryPath, a.syncEngine, a.smtpClient) + policiesHandler := policies.NewHandler(a.db) // Protected routes protectedRouter := http.NewServeMux() usersHandler.RegisterRoutes(protectedRouter) + policiesHandler.RegisterRoutes(protectedRouter) // Wrap protected routes with auth middleware router.Handle("/api/", authMiddleware.RequireAuth(protectedRouter)) diff --git a/src/internal/database/database.go b/src/internal/database/database.go index 9117df0..fc6e60a 100644 --- a/src/internal/database/database.go +++ b/src/internal/database/database.go @@ -187,6 +187,36 @@ BEGIN INSERT INTO sync_logs (operation, username, success) VALUES ('DELETE', OLD.username, TRUE); END; +COMMIT;`, + }, + { + version: 2, + name: "policies", + sql: `BEGIN TRANSACTION; + +-- Access control policies table +CREATE TABLE IF NOT EXISTS policies ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + domain TEXT NOT NULL DEFAULT '[]', + resources TEXT NOT NULL DEFAULT '[]', + methods TEXT NOT NULL DEFAULT '[]', + subjects TEXT NOT NULL DEFAULT '[]', + policy TEXT NOT NULL DEFAULT 'deny' + CHECK (policy IN ('bypass', 'one_factor', 'two_factor', 'deny')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_policies_name ON policies(name); +CREATE INDEX IF NOT EXISTS idx_policies_policy ON policies(policy); + +CREATE TRIGGER IF NOT EXISTS update_policies_timestamp +AFTER UPDATE ON policies +BEGIN + UPDATE policies SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; +END; + COMMIT;`, }, } diff --git a/src/internal/policies/engine.go b/src/internal/policies/engine.go new file mode 100644 index 0000000..ca29e74 --- /dev/null +++ b/src/internal/policies/engine.go @@ -0,0 +1,136 @@ +package policies + +import ( + "math/rand" + "regexp" + "strings" + "time" +) + +// generateID creates a short unique identifier like "pol_93f8s2". +func generateID() string { + const charset = "abcdefghijklmnopqrstuvwxyz0123456789" + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + b := make([]byte, 6) + for i := range b { + b[i] = charset[rng.Intn(len(charset))] + } + return "pol_" + string(b) +} + +// subjectPatterns extracts the subject patterns relevant to a user. +// Returns patterns like "user:" and "group:" for each group. +func subjectPatterns(username string, groups []string) []string { + patterns := []string{"user:" + username} + for _, g := range groups { + patterns = append(patterns, "group:"+g) + } + return patterns +} + +// matchSubject checks if a subject inner-array (e.g. ["group:admins", "user:admin"]) +// matches any of the given subject patterns. +func matchSubject(subjectGroup []string, patterns []string) (string, bool) { + for _, sg := range subjectGroup { + for _, pat := range patterns { + if sg == pat { + // Return a human-readable explanation + if strings.HasPrefix(sg, "user:") { + return "Matched via direct username: " + strings.TrimPrefix(sg, "user:"), true + } + return "Matched via group membership: " + strings.TrimPrefix(sg, "group:"), true + } + } + } + return "", false +} + +// matchDomain checks if a domain matches a pattern (supports wildcard '*'). +func matchDomain(domain, pattern string) bool { + // Convert glob-style pattern to regex + // e.g. "*.example.com" -> "^.*\\.example\\.com$" + reStr := "^" + regexp.QuoteMeta(pattern) + "$" + reStr = strings.ReplaceAll(reStr, `\*`, `.*`) + matched, _ := regexp.MatchString(reStr, domain) + return matched +} + +// matchResource checks if a path matches a resource pattern (regex). +func matchResource(path, pattern string) bool { + matched, _ := regexp.MatchString(pattern, path) + return matched +} + +// matchMethod checks if a method is in the policy's allowed methods list. +// An empty methods list means all methods match. +func matchMethod(method string, methods []string) bool { + if len(methods) == 0 { + return true + } + for _, m := range methods { + if strings.EqualFold(m, method) { + return true + } + } + return false +} + +// Evaluate checks whether a policy matches a given request context. +// Returns true if domain, resource, method, and subjects all match. +func (p *Policy) Evaluate(domain, path, method string, subjectPatterns []string) (string, bool) { + // Domain match + domainMatch := false + for _, d := range p.Domain { + if matchDomain(domain, d) { + domainMatch = true + break + } + } + if !domainMatch { + return "", false + } + + // Resource match (if any resources defined) + if len(p.Resources) > 0 { + resourceMatch := false + for _, r := range p.Resources { + if matchResource(path, r) { + resourceMatch = true + break + } + } + if !resourceMatch { + return "", false + } + } + + // Method match (if any methods defined) + if !matchMethod(method, p.Methods) { + return "", false + } + + // Subject match (if any subjects defined) + if len(p.Subjects) > 0 { + // Each element in Subjects is a JSON array of alternatives, + // e.g. ["group:admins", "user:admin"] + subjectMatch := false + for _, s := range p.Subjects { + // For now subject strings are stored as JSON arrays + // We parse the pattern from the stored subjects field + for _, pat := range subjectPatterns { + if s == pat { + subjectMatch = true + break + } + } + if subjectMatch { + break + } + } + if !subjectMatch { + return "", false + } + } + + return p.Policy, true +} diff --git a/src/internal/policies/handler.go b/src/internal/policies/handler.go new file mode 100644 index 0000000..ca9eea3 --- /dev/null +++ b/src/internal/policies/handler.go @@ -0,0 +1,335 @@ +package policies + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" +) + +// Handler serves HTTP endpoints for policy management. +type Handler struct { + store *Store + db *sql.DB +} + +// NewHandler creates a new policy HTTP handler. +func NewHandler(db *sql.DB) *Handler { + return &Handler{ + store: NewStore(db), + db: db, + } +} + +// RegisterRoutes registers all policy routes on the given mux. +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/policies", h.handleList) + mux.HandleFunc("POST /api/policies", h.handleCreate) + mux.HandleFunc("GET /api/policies/{policy_id}", h.handleGet) + mux.HandleFunc("PUT /api/policies/{policy_id}", h.handleUpdate) + mux.HandleFunc("DELETE /api/policies/{policy_id}", h.handleDelete) + mux.HandleFunc("POST /api/policies/verify", h.handleVerify) + mux.HandleFunc("GET /api/users/{username}/policies", h.handleUserPolicies) +} + +// handleList returns all policies. +func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) { + policies, err := h.store.List() + if err != nil { + log.Printf("[ERROR] policy list: %v", err) + writePolicyError(w, http.StatusInternalServerError, "Failed to list policies") + return + } + if policies == nil { + policies = []Policy{} + } + writeJSON(w, http.StatusOK, policies) +} + +// handleCreate creates a new policy. +func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) { + var input PolicyInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writePolicyError(w, http.StatusBadRequest, fmt.Sprintf("Invalid JSON: %v", err)) + return + } + + if err := validateInput(input); err != nil { + writePolicyError(w, http.StatusBadRequest, err.Error()) + return + } + + policy, err := h.store.Create(input) + if err != nil { + log.Printf("[ERROR] policy create: %v", err) + writePolicyError(w, http.StatusInternalServerError, "Failed to create policy") + return + } + + writeJSON(w, http.StatusCreated, policy) +} + +// handleGet returns a single policy by ID. +func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("policy_id") + if id == "" { + writePolicyError(w, http.StatusBadRequest, "policy_id is required") + return + } + + policy, err := h.store.GetByID(id) + if err != nil { + log.Printf("[ERROR] policy get: %v", err) + writePolicyError(w, http.StatusInternalServerError, "Failed to retrieve policy") + return + } + if policy == nil { + writePolicyError(w, http.StatusNotFound, "Policy not found") + return + } + + writeJSON(w, http.StatusOK, policy) +} + +// handleUpdate replaces a policy by ID. +func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("policy_id") + if id == "" { + writePolicyError(w, http.StatusBadRequest, "policy_id is required") + return + } + + var input PolicyInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writePolicyError(w, http.StatusBadRequest, fmt.Sprintf("Invalid JSON: %v", err)) + return + } + + if err := validateInput(input); err != nil { + writePolicyError(w, http.StatusBadRequest, err.Error()) + return + } + + policy, err := h.store.Update(id, input) + if err != nil { + log.Printf("[ERROR] policy update: %v", err) + writePolicyError(w, http.StatusInternalServerError, "Failed to update policy") + return + } + if policy == nil { + writePolicyError(w, http.StatusNotFound, "Policy not found") + return + } + + writeJSON(w, http.StatusOK, policy) +} + +// handleDelete deletes a policy by ID. +func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("policy_id") + if id == "" { + writePolicyError(w, http.StatusBadRequest, "policy_id is required") + return + } + + deleted, err := h.store.Delete(id) + if err != nil { + log.Printf("[ERROR] policy delete: %v", err) + writePolicyError(w, http.StatusInternalServerError, "Failed to delete policy") + return + } + if !deleted { + writePolicyError(w, http.StatusNotFound, "Policy not found") + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("Policy %s deleted", id), + }) +} + +// handleVerify performs a dry-run policy evaluation. +func (h *Handler) handleVerify(w http.ResponseWriter, r *http.Request) { + var req VerifyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writePolicyError(w, http.StatusBadRequest, fmt.Sprintf("Invalid JSON: %v", err)) + return + } + + if req.Domain == "" || req.Path == "" { + writePolicyError(w, http.StatusBadRequest, "domain and path are required") + return + } + + if req.Method == "" { + req.Method = "GET" + } + + // Build subject patterns + patterns := subjectPatterns(req.Username, req.Groups) + + // Get all policies + allPolicies, err := h.store.List() + if err != nil { + log.Printf("[ERROR] policy verify list: %v", err) + writePolicyError(w, http.StatusInternalServerError, "Failed to load policies") + return + } + + // Evaluate each policy + for _, p := range allPolicies { + action, matched := p.Evaluate(req.Domain, req.Path, req.Method, patterns) + if matched { + writeJSON(w, http.StatusOK, VerifyResponse{ + Matched: true, + PolicyID: p.ID, + PolicyName: p.Name, + ActionRequired: action, + }) + return + } + } + + // No match + writeJSON(w, http.StatusOK, VerifyResponse{Matched: false}) +} + +// handleUserPolicies returns policies that match a user (by username or group). +func (h *Handler) handleUserPolicies(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("username") + if username == "" { + writePolicyError(w, http.StatusBadRequest, "Username is required") + return + } + + // Query the user's groups from the users table + groups, err := h.getUserGroups(username) + if err != nil { + log.Printf("[ERROR] user policies - get groups: %v", err) + writePolicyError(w, http.StatusInternalServerError, "Failed to retrieve user") + return + } + if groups == nil { + writePolicyError(w, http.StatusNotFound, "User not found") + return + } + + patterns := subjectPatterns(username, groups) + + allPolicies, err := h.store.List() + if err != nil { + log.Printf("[ERROR] user policies list: %v", err) + writePolicyError(w, http.StatusInternalServerError, "Failed to load policies") + return + } + + var matches []UserPolicyMatch + for _, p := range allPolicies { + matchReason, matched := h.policyMatchesUser(&p, patterns) + if matched { + matches = append(matches, UserPolicyMatch{ + PolicyID: p.ID, + Name: p.Name, + MatchReason: matchReason, + Policy: p.Policy, + Domain: p.Domain, + Resources: p.Resources, + }) + } + } + + if matches == nil { + matches = []UserPolicyMatch{} + } + + writeJSON(w, http.StatusOK, matches) +} + +// policyMatchesUser checks if a policy's subjects match any of the user's patterns. +func (h *Handler) policyMatchesUser(p *Policy, patterns []string) (string, bool) { + if len(p.Subjects) == 0 { + // No subject restrictions — policy applies to everyone + return "Policy has no subject restrictions (applies to all users)", true + } + + for _, sub := range p.Subjects { + for _, pat := range patterns { + if sub == pat { + if strings.HasPrefix(sub, "user:") { + return "Matched via direct username: " + strings.TrimPrefix(sub, "user:"), true + } + return "Matched via group membership: " + strings.TrimPrefix(sub, "group:"), true + } + } + } + + return "", false +} + +// getUserGroups retrieves a user's groups from the database. +// Returns nil, nil if the user doesn't exist. +func (h *Handler) getUserGroups(username string) ([]string, error) { + var groupsJSON string + err := h.db.QueryRow("SELECT groups FROM users WHERE username = ?", username).Scan(&groupsJSON) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + // Parse groups JSON + groupsJSON = strings.TrimSpace(groupsJSON) + groupsJSON = strings.Trim(groupsJSON, "[]") + if groupsJSON == "" { + return []string{}, nil + } + + parts := strings.Split(groupsJSON, ",") + groups := make([]string, 0, len(parts)) + for _, p := range parts { + g := strings.Trim(p, `" `) + if g != "" { + groups = append(groups, g) + } + } + return groups, nil +} + +// --- helpers --- + +func validateInput(input PolicyInput) error { + if input.Name == "" { + return fmt.Errorf("name is required") + } + if len(input.Domain) == 0 { + return fmt.Errorf("domain is required (at least one domain pattern)") + } + switch input.Policy { + case "bypass", "one_factor", "two_factor", "deny": + // valid + default: + return fmt.Errorf("policy must be one of: bypass, one_factor, two_factor, deny") + } + return nil +} + +func writePolicyError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": map[string]interface{}{ + "code": status, + "message": msg, + }, + }) +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} diff --git a/src/internal/policies/models.go b/src/internal/policies/models.go new file mode 100644 index 0000000..20e1854 --- /dev/null +++ b/src/internal/policies/models.go @@ -0,0 +1,51 @@ +package policies + +// Policy represents an access control policy rule as stored in the database. +type Policy struct { + ID string `json:"id"` + Name string `json:"name"` + Domain []string `json:"domain"` + Resources []string `json:"resources,omitempty"` + Methods []string `json:"methods,omitempty"` + Subjects []string `json:"subjects,omitempty"` + Policy string `json:"policy"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// PolicyInput is the request body for creating or updating a policy. +type PolicyInput struct { + Name string `json:"name"` + Domain []string `json:"domain"` + Resources []string `json:"resources,omitempty"` + Methods []string `json:"methods,omitempty"` + Subjects []string `json:"subjects,omitempty"` + Policy string `json:"policy"` +} + +// UserPolicyMatch describes a policy that matched a user via direct or group membership. +type UserPolicyMatch struct { + PolicyID string `json:"policy_id"` + Name string `json:"name"` + MatchReason string `json:"match_reason"` + Policy string `json:"policy"` + Domain []string `json:"domain,omitempty"` + Resources []string `json:"resources,omitempty"` +} + +// VerifyRequest is the request body for the policy verification endpoint. +type VerifyRequest struct { + Domain string `json:"domain"` + Path string `json:"path"` + Method string `json:"method,omitempty"` + Username string `json:"username,omitempty"` + Groups []string `json:"groups,omitempty"` +} + +// VerifyResponse is the response for the policy verification endpoint. +type VerifyResponse struct { + Matched bool `json:"matched"` + PolicyID string `json:"policy_id,omitempty"` + PolicyName string `json:"policy_name,omitempty"` + ActionRequired string `json:"action_required,omitempty"` +} diff --git a/src/internal/policies/store.go b/src/internal/policies/store.go new file mode 100644 index 0000000..e991948 --- /dev/null +++ b/src/internal/policies/store.go @@ -0,0 +1,209 @@ +package policies + +import ( + "database/sql" + "encoding/json" + "fmt" + "strings" +) + +// Store handles database operations for policies. +type Store struct { + db *sql.DB +} + +// NewStore creates a new policy store. +func NewStore(db *sql.DB) *Store { + return &Store{db: db} +} + +// List returns all policies ordered by name. +func (s *Store) List() ([]Policy, error) { + rows, err := s.db.Query(` + SELECT id, name, domain, resources, methods, subjects, policy, created_at, updated_at + FROM policies + ORDER BY name + `) + if err != nil { + return nil, fmt.Errorf("failed to query policies: %w", err) + } + defer rows.Close() + + var policies []Policy + for rows.Next() { + p, err := scanPolicy(rows) + if err != nil { + return nil, err + } + policies = append(policies, p) + } + return policies, rows.Err() +} + +// GetByID returns a single policy by its ID. +func (s *Store) GetByID(id string) (*Policy, error) { + row := s.db.QueryRow(` + SELECT id, name, domain, resources, methods, subjects, policy, created_at, updated_at + FROM policies WHERE id = ? + `, id) + + p, err := scanPolicyRow(row) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to scan policy: %w", err) + } + return &p, nil +} + +// Create inserts a new policy and returns it with the generated ID. +func (s *Store) Create(input PolicyInput) (*Policy, error) { + id := generateID() + domainJSON := mustMarshal(input.Domain) + resourcesJSON := mustMarshal(input.Resources) + methodsJSON := mustMarshal(input.Methods) + subjectsJSON := mustMarshal(input.Subjects) + + _, err := s.db.Exec(` + INSERT INTO policies (id, name, domain, resources, methods, subjects, policy) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, id, input.Name, domainJSON, resourcesJSON, methodsJSON, subjectsJSON, input.Policy) + if err != nil { + return nil, fmt.Errorf("failed to create policy: %w", err) + } + + return s.GetByID(id) +} + +// Update replaces an existing policy by ID. +func (s *Store) Update(id string, input PolicyInput) (*Policy, error) { + domainJSON := mustMarshal(input.Domain) + resourcesJSON := mustMarshal(input.Resources) + methodsJSON := mustMarshal(input.Methods) + subjectsJSON := mustMarshal(input.Subjects) + + res, err := s.db.Exec(` + UPDATE policies + SET name = ?, domain = ?, resources = ?, methods = ?, subjects = ?, policy = ? + WHERE id = ? + `, input.Name, domainJSON, resourcesJSON, methodsJSON, subjectsJSON, input.Policy, id) + if err != nil { + return nil, fmt.Errorf("failed to update policy: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return nil, nil + } + return s.GetByID(id) +} + +// Delete removes a policy by ID. Returns true if a row was deleted. +func (s *Store) Delete(id string) (bool, error) { + res, err := s.db.Exec("DELETE FROM policies WHERE id = ?", id) + if err != nil { + return false, fmt.Errorf("failed to delete policy: %w", err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// ListBySubjects returns policies whose subjects match any of the given subject patterns. +// A subject pattern is "user:" or "group:". +func (s *Store) ListBySubjects(subjects []string) ([]Policy, error) { + if len(subjects) == 0 { + return nil, nil + } + + // Build a query that searches the JSON subjects column for each subject. + // The subjects column stores JSON like: [["group:admins","user:admin"],["user:test.user1"]] + // We search if any element of the top-level array contains one of our subject patterns. + conditions := make([]string, len(subjects)) + args := make([]interface{}, len(subjects)) + for i, sub := range subjects { + conditions[i] = "subjects LIKE ?" + args[i] = "%" + sub + "%" + } + + query := ` + SELECT id, name, domain, resources, methods, subjects, policy, created_at, updated_at + FROM policies + WHERE ` + strings.Join(conditions, " OR ") + ` + ORDER BY name + ` + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query policies by subjects: %w", err) + } + defer rows.Close() + + var policies []Policy + for rows.Next() { + p, err := scanPolicy(rows) + if err != nil { + return nil, err + } + policies = append(policies, p) + } + return policies, rows.Err() +} + +// --- helpers --- + +type scannable interface { + Scan(dest ...interface{}) error +} + +func scanPolicy(row scannable) (Policy, error) { + var p Policy + var domainJSON, resourcesJSON, methodsJSON, subjectsJSON string + var createdAt, updatedAt sql.NullString + + err := row.Scan( + &p.ID, &p.Name, &domainJSON, &resourcesJSON, + &methodsJSON, &subjectsJSON, &p.Policy, + &createdAt, &updatedAt, + ) + if err != nil { + return p, err + } + + p.Domain = mustUnmarshalSlice(domainJSON) + p.Resources = mustUnmarshalSlice(resourcesJSON) + p.Methods = mustUnmarshalSlice(methodsJSON) + p.Subjects = mustUnmarshalSlice(subjectsJSON) + p.CreatedAt = nullStringVal(createdAt) + p.UpdatedAt = nullStringVal(updatedAt) + return p, nil +} + +func scanPolicyRow(row *sql.Row) (Policy, error) { + return scanPolicy(row) +} + +func mustMarshal(v interface{}) string { + b, err := json.Marshal(v) + if err != nil { + return "[]" + } + return string(b) +} + +func mustUnmarshalSlice(s string) []string { + if s == "" || s == "[]" { + return nil + } + var out []string + if err := json.Unmarshal([]byte(s), &out); err != nil { + return nil + } + return out +} + +func nullStringVal(ns sql.NullString) string { + if ns.Valid { + return ns.String + } + return "" +} diff --git a/src/migrations/002_policies.sql b/src/migrations/002_policies.sql new file mode 100644 index 0000000..c7cd4d8 --- /dev/null +++ b/src/migrations/002_policies.sql @@ -0,0 +1,32 @@ +-- Authelia API Schema: Policies +-- Version: 2.0 +-- Description: Access control policies table + +BEGIN TRANSACTION; + +-- Access control policies table +CREATE TABLE IF NOT EXISTS policies ( + id TEXT PRIMARY KEY, -- e.g. "pol_93f8s2" + name TEXT NOT NULL, + domain TEXT NOT NULL DEFAULT '[]', -- JSON array of domain patterns + resources TEXT NOT NULL DEFAULT '[]', -- JSON array of resource regexes + methods TEXT NOT NULL DEFAULT '[]', -- JSON array of HTTP methods + subjects TEXT NOT NULL DEFAULT '[]', -- JSON array of subject arrays + policy TEXT NOT NULL DEFAULT 'deny' -- bypass, one_factor, two_factor, deny + CHECK (policy IN ('bypass', 'one_factor', 'two_factor', 'deny')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Index for policy lookups +CREATE INDEX IF NOT EXISTS idx_policies_name ON policies(name); +CREATE INDEX IF NOT EXISTS idx_policies_policy ON policies(policy); + +-- Trigger for updated_at +CREATE TRIGGER IF NOT EXISTS update_policies_timestamp +AFTER UPDATE ON policies +BEGIN + UPDATE policies SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; +END; + +COMMIT;