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",
}
}