feat: add policy management endpoints

New API endpoints matching openapi.yml spec:
  GET    /api/policies                - List policies
  POST   /api/policies                - Create policy
  GET    /api/policies/{policy_id}    - Get policy
  PUT    /api/policies/{policy_id}    - Update policy
  DELETE /api/policies/{policy_id}    - Delete policy
  POST   /api/policies/verify         - Dry-run policy matching
  GET    /api/users/{username}/policies - User policy evaluation

New files:
  src/internal/policies/models.go    - Policy/input/response structs
  src/internal/policies/store.go     - DB CRUD operations
  src/internal/policies/engine.go    - Domain/resource/method/subject matching
  src/internal/policies/handler.go   - HTTP handlers for all 7 endpoints
  src/migrations/002_policies.sql    - policies table schema

Modified:
  src/internal/database/database.go  - Register migration 2
  src/cmd/server/main.go             - Wire up policies handler + routes
This commit is contained in:
Claus Lohmar 2026-07-15 14:33:09 +00:00
parent fae2363360
commit 4ab00e0761
7 changed files with 796 additions and 0 deletions

View file

@ -18,6 +18,7 @@ import (
"authelia-api/internal/bootstrap" "authelia-api/internal/bootstrap"
"authelia-api/internal/config" "authelia-api/internal/config"
"authelia-api/internal/database" "authelia-api/internal/database"
"authelia-api/internal/policies"
"authelia-api/internal/smtp" "authelia-api/internal/smtp"
"authelia-api/internal/sync" "authelia-api/internal/sync"
) )
@ -170,10 +171,12 @@ func (a *Application) Start() error {
// Create API handlers // Create API handlers
usersHandler := api.NewUsersHandler(a.db, a.config.AutheliaBinaryPath, a.syncEngine, a.smtpClient) usersHandler := api.NewUsersHandler(a.db, a.config.AutheliaBinaryPath, a.syncEngine, a.smtpClient)
policiesHandler := policies.NewHandler(a.db)
// Protected routes // Protected routes
protectedRouter := http.NewServeMux() protectedRouter := http.NewServeMux()
usersHandler.RegisterRoutes(protectedRouter) usersHandler.RegisterRoutes(protectedRouter)
policiesHandler.RegisterRoutes(protectedRouter)
// Wrap protected routes with auth middleware // Wrap protected routes with auth middleware
router.Handle("/api/", authMiddleware.RequireAuth(protectedRouter)) router.Handle("/api/", authMiddleware.RequireAuth(protectedRouter))

View file

@ -187,6 +187,36 @@ BEGIN
INSERT INTO sync_logs (operation, username, success) VALUES ('DELETE', OLD.username, TRUE); INSERT INTO sync_logs (operation, username, success) VALUES ('DELETE', OLD.username, TRUE);
END; 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;`, COMMIT;`,
}, },
} }

View file

@ -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:<username>" and "group:<groupname>" 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
}

View file

@ -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)
}

View file

@ -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"`
}

View file

@ -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:<username>" or "group:<groupname>".
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 ""
}

View file

@ -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;