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:
parent
fae2363360
commit
4ab00e0761
7 changed files with 796 additions and 0 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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;`,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
136
src/internal/policies/engine.go
Normal file
136
src/internal/policies/engine.go
Normal 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
|
||||
}
|
||||
335
src/internal/policies/handler.go
Normal file
335
src/internal/policies/handler.go
Normal 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)
|
||||
}
|
||||
51
src/internal/policies/models.go
Normal file
51
src/internal/policies/models.go
Normal 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"`
|
||||
}
|
||||
209
src/internal/policies/store.go
Normal file
209
src/internal/policies/store.go
Normal 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 ""
|
||||
}
|
||||
32
src/migrations/002_policies.sql
Normal file
32
src/migrations/002_policies.sql
Normal 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;
|
||||
Loading…
Reference in a new issue