readysite / website / internal / content / rules / auth.go
5.8 KB
auth.go
package rules

import (
	"net/http"

	"github.com/readysite/readysite/website/internal/access"
	"github.com/readysite/readysite/website/models"
)

// AuthResult represents the result of an authorization check.
type AuthResult struct {
	Allowed   bool
	SQLFilter string // For list rules with expressions
	Params    []any  // Parameters for SQL filter
	Status    int    // HTTP status code to return on denial
	Message   string // Error message on denial
}

// Authorizer handles authorization checks for collection operations.
type Authorizer struct {
	user       *models.User
	collection *models.Collection
	ruleCtx    *Context
}

// NewAuthorizer creates an Authorizer from a request and collection.
func NewAuthorizer(r *http.Request, collection *models.Collection, body map[string]any) *Authorizer {
	user := access.GetUserFromJWT(r)
	return &Authorizer{
		user:       user,
		collection: collection,
		ruleCtx:    NewContext(r, user, body),
	}
}

// User returns the authenticated user (nil if unauthenticated).
func (a *Authorizer) User() *models.User {
	return a.user
}

// Context returns the rule evaluation context.
func (a *Authorizer) Context() *Context {
	return a.ruleCtx
}

// IsAuthenticated returns true if the user is authenticated.
func (a *Authorizer) IsAuthenticated() bool {
	return a.user != nil
}

// IsAdmin returns true if the user is an admin.
func (a *Authorizer) IsAdmin() bool {
	return a.user != nil && a.user.Role == "admin"
}

// CanList checks if the user can list records from the collection.
// Returns an AuthResult with SQLFilter set if the rule is an expression.
func (a *Authorizer) CanList() AuthResult {
	listRule := a.collection.ListRule

	if listRule != "" {
		ruleType := ParseRuleType(listRule)
		switch ruleType {
		case RuleTypeLocked:
			if !a.IsAdmin() {
				return a.denyResult()
			}
			return AuthResult{Allowed: true}

		case RuleTypePublic:
			return AuthResult{Allowed: true}

		case RuleTypeExpression:
			// Convert rule to SQL filter
			sqlFilter, params, err := ToSQLFilter(listRule, a.ruleCtx)
			if err != nil {
				return AuthResult{
					Allowed: false,
					Status:  http.StatusInternalServerError,
					Message: "Invalid list rule: " + err.Error(),
				}
			}
			return AuthResult{
				Allowed:   true,
				SQLFilter: sqlFilter,
				Params:    params,
			}
		}
	}

	// Fall back to static ACL check
	if !access.CheckAccess(a.user, access.ResourceCollection, a.collection.ID, access.PermRead) {
		return a.denyResult()
	}
	return AuthResult{Allowed: true}
}

// CanView checks if the user can view a specific record.
// The record parameter should be the document converted to a map.
func (a *Authorizer) CanView(record map[string]any) AuthResult {
	viewRule := a.collection.ViewRule

	if viewRule != "" {
		allowed, err := Evaluate(viewRule, a.ruleCtx, record)
		if err != nil || !allowed {
			return AuthResult{
				Allowed: false,
				Status:  http.StatusNotFound,
				Message: "Record not found",
			}
		}
		return AuthResult{Allowed: true}
	}

	// Fall back to static ACL check
	if !access.CheckAccess(a.user, access.ResourceCollection, a.collection.ID, access.PermRead) {
		return AuthResult{
			Allowed: false,
			Status:  http.StatusNotFound,
			Message: "Record not found",
		}
	}
	return AuthResult{Allowed: true}
}

// CanCreate checks if the user can create a record with the given data.
func (a *Authorizer) CanCreate(data map[string]any) AuthResult {
	createRule := a.collection.CreateRule

	if createRule != "" {
		allowed, err := Evaluate(createRule, a.ruleCtx, data)
		if err != nil {
			return AuthResult{
				Allowed: false,
				Status:  http.StatusBadRequest,
				Message: "Rule evaluation error: " + err.Error(),
			}
		}
		if !allowed {
			return a.denyResult()
		}
		return AuthResult{Allowed: true}
	}

	// Fall back to static ACL check
	if !access.CheckAccess(a.user, access.ResourceCollection, a.collection.ID, access.PermWrite) {
		return a.denyResult()
	}
	return AuthResult{Allowed: true}
}

// CanUpdate checks if the user can update a record.
// currentRecord is the existing record data, updates is the proposed changes.
func (a *Authorizer) CanUpdate(currentRecord map[string]any) AuthResult {
	updateRule := a.collection.UpdateRule

	if updateRule != "" {
		allowed, err := Evaluate(updateRule, a.ruleCtx, currentRecord)
		if err != nil || !allowed {
			return a.denyResultNotFound()
		}
		return AuthResult{Allowed: true}
	}

	// Fall back to static ACL check
	if !access.CheckAccess(a.user, access.ResourceCollection, a.collection.ID, access.PermWrite) {
		return a.denyResultNotFound()
	}
	return AuthResult{Allowed: true}
}

// CanDelete checks if the user can delete a record.
func (a *Authorizer) CanDelete(record map[string]any) AuthResult {
	deleteRule := a.collection.DeleteRule

	if deleteRule != "" {
		allowed, err := Evaluate(deleteRule, a.ruleCtx, record)
		if err != nil || !allowed {
			return a.denyResultNotFound()
		}
		return AuthResult{Allowed: true}
	}

	// Fall back to static ACL check
	if !access.CheckAccess(a.user, access.ResourceCollection, a.collection.ID, access.PermDelete) {
		return a.denyResultNotFound()
	}
	return AuthResult{Allowed: true}
}

// denyResult returns an appropriate denial result based on authentication status.
func (a *Authorizer) denyResult() AuthResult {
	if !a.IsAuthenticated() {
		return AuthResult{
			Allowed: false,
			Status:  http.StatusUnauthorized,
			Message: "Authentication required",
		}
	}
	return AuthResult{
		Allowed: false,
		Status:  http.StatusForbidden,
		Message: "Access denied",
	}
}

// denyResultNotFound returns a 404 to avoid leaking record existence.
func (a *Authorizer) denyResultNotFound() AuthResult {
	if !a.IsAuthenticated() {
		return AuthResult{
			Allowed: false,
			Status:  http.StatusUnauthorized,
			Message: "Authentication required",
		}
	}
	return AuthResult{
		Allowed: false,
		Status:  http.StatusNotFound,
		Message: "Record not found",
	}
}
← Back