Files
adamandClaude Opus 5.5 9fc23310bd Add blocking: blocklists and block/allow rules with BIND response policy zones
jarld downloads blocklists (Adblock, hosts or plain domains), checks them
daily and reloads named only when a list changed; your own block and
allow rules take precedence, and the most specific rule wins. Lists and
rules apply per view (default: every view that offers recursion). Web UI
Blocking page with name checks, jarl blocklist and jarl block, the
/blocking API, blocked counts in the statistics. Database schema 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:24:19 +00:00

386 lines
9.8 KiB
Go

package auth
import (
"fmt"
"regexp"
"sort"
"strings"
"sync"
"jarl/internal/model"
)
// Resources that grants apply to.
const (
ResZones = "zones" // zone definitions and zone options
ResRecords = "records" // records in zones
ResViews = "views" // views and view options
ResACLs = "acls"
ResTSIG = "tsig" // TSIG keys
ResOptions = "options" // server options, top-level statements, settings
ResAudit = "audit" // audit log and everyone's changesets
ResStats = "stats" // query statistics
ResBlocking = "blocking" // blocklists and block/allow rules
ResAll = "*"
)
// Resources lists the resources in display order.
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking}
// Access levels.
const (
Read = "read"
Write = "write"
)
// DefaultViewName is used for matching view filters against zones that
// are not in a view.
const DefaultViewName = "_default"
// ValidateGrant checks a grant and normalizes it.
func ValidateGrant(g *model.Grant) error {
g.Resource = strings.ToLower(strings.TrimSpace(g.Resource))
g.Access = strings.ToLower(strings.TrimSpace(g.Access))
ok := g.Resource == ResAll
for _, r := range Resources {
ok = ok || g.Resource == r
}
if !ok {
return fmt.Errorf("unknown resource %q (use one of %s or *)", g.Resource, strings.Join(Resources, ", "))
}
if g.Access != Read && g.Access != Write {
return fmt.Errorf("access must be read or write, not %q", g.Access)
}
norm := func(list []string, lower bool, trimDot bool) []string {
var out []string
for _, s := range list {
s = strings.TrimSpace(s)
if lower {
s = strings.ToLower(s)
}
if trimDot && s != "." {
s = strings.TrimSuffix(s, ".")
}
if s != "" {
out = append(out, s)
}
}
return out
}
g.Zones = norm(g.Zones, true, true)
g.Views = norm(g.Views, false, false)
g.Names = norm(g.Names, true, true)
g.Types = norm(g.Types, false, false)
for i, t := range g.Types {
g.Types[i] = strings.ToUpper(t)
}
if (len(g.Names) > 0 || len(g.Types) > 0) && g.Resource != ResRecords {
return fmt.Errorf("names and types filters only apply to the records resource")
}
if (len(g.Zones) > 0 || len(g.Views) > 0) && g.Resource != ResRecords && g.Resource != ResZones && g.Resource != ResViews {
return fmt.Errorf("zone and view filters only apply to the zones, records and views resources")
}
for _, p := range append(append(append([]string{}, g.Zones...), g.Names...), g.Views...) {
if strings.ContainsAny(p, " \t\n") {
return fmt.Errorf("invalid pattern %q", p)
}
}
return nil
}
// ParseGrant parses the compact grant syntax used by the CLI and docs:
//
// resource:access [zones=a,b] [views=x] [names=pattern] [types=TXT,CNAME]
//
// For example "records:write zones=example.com names=_acme-challenge.* types=TXT".
func ParseGrant(s string) (model.Grant, error) {
var g model.Grant
fields := strings.Fields(s)
if len(fields) == 0 {
return g, fmt.Errorf("empty grant")
}
res, acc, ok := strings.Cut(fields[0], ":")
if !ok {
return g, fmt.Errorf("grant %q must start with resource:access, e.g. records:write", s)
}
g.Resource, g.Access = res, acc
for _, f := range fields[1:] {
k, v, ok := strings.Cut(f, "=")
if !ok || v == "" {
return g, fmt.Errorf("invalid filter %q (use key=value)", f)
}
vals := strings.Split(v, ",")
switch strings.ToLower(k) {
case "zone", "zones":
g.Zones = append(g.Zones, vals...)
case "view", "views":
g.Views = append(g.Views, vals...)
case "name", "names":
g.Names = append(g.Names, vals...)
case "type", "types":
g.Types = append(g.Types, vals...)
default:
return g, fmt.Errorf("unknown filter %q (use zones, views, names or types)", k)
}
}
return g, ValidateGrant(&g)
}
// FormatGrant returns the compact form of a grant.
func FormatGrant(g model.Grant) string {
parts := []string{g.Resource + ":" + g.Access}
add := func(k string, vals []string) {
if len(vals) > 0 {
parts = append(parts, k+"="+strings.Join(vals, ","))
}
}
add("zones", g.Zones)
add("views", g.Views)
add("names", g.Names)
add("types", g.Types)
return strings.Join(parts, " ")
}
// Action is something a principal wants to do.
type Action struct {
Resource string
Write bool
Zone string // zone FQDN (zones, records)
View string // view name ("" for zones without a view)
Name string // record owner FQDN (records)
Type string // record type (records)
}
func (a Action) String() string {
acc := Read
if a.Write {
acc = Write
}
s := a.Resource + ":" + acc
if a.Zone != "" {
s += " zone " + strings.TrimSuffix(a.Zone, ".")
}
if a.View != "" {
s += " view " + a.View
}
if a.Name != "" {
s += " name " + strings.TrimSuffix(a.Name, ".")
}
if a.Type != "" {
s += " type " + a.Type
}
return s
}
var globCache sync.Map // pattern -> *regexp.Regexp
func globMatch(pattern, s string) bool {
if pattern == "*" {
return true
}
if !strings.ContainsAny(pattern, "*?") {
return pattern == s
}
v, ok := globCache.Load(pattern)
if !ok {
var sb strings.Builder
sb.WriteString("^")
for _, c := range pattern {
switch c {
case '*':
sb.WriteString(".*")
case '?':
sb.WriteString(".")
default:
sb.WriteString(regexp.QuoteMeta(string(c)))
}
}
sb.WriteString("$")
v, _ = globCache.LoadOrStore(pattern, regexp.MustCompile(sb.String()))
}
return v.(*regexp.Regexp).MatchString(s)
}
func matchAny(patterns []string, s string, fold bool) bool {
if len(patterns) == 0 {
return true
}
if fold {
s = strings.ToLower(s)
}
for _, p := range patterns {
if globMatch(p, s) {
return true
}
}
return false
}
func trimName(n string) string {
if n == "." {
return n
}
return strings.TrimSuffix(strings.ToLower(n), ".")
}
// GrantAllows reports whether g allows action a.
func GrantAllows(g model.Grant, a Action) bool {
if g.Resource != ResAll && g.Resource != a.Resource {
return false
}
if a.Write && g.Access != Write {
return false
}
if a.Zone != "" && !matchAny(g.Zones, trimName(a.Zone), true) {
return false
}
view := a.View
if view == "" {
view = DefaultViewName
}
if (a.Zone != "" || a.Resource == ResViews) && !matchAny(g.Views, view, false) {
return false
}
if a.Name != "" && !matchAny(g.Names, trimName(a.Name), true) {
return false
}
if a.Type != "" && len(g.Types) > 0 {
found := false
for _, t := range g.Types {
if strings.EqualFold(t, a.Type) {
found = true
break
}
}
if !found {
return false
}
}
return true
}
// GrantsAllow reports whether any grant allows a.
func GrantsAllow(gs []model.Grant, a Action) bool {
for _, g := range gs {
if GrantAllows(g, a) {
return true
}
}
return false
}
// Principal is an authenticated identity.
type Principal struct {
User *model.User
Token *model.Token // nil for sessions and local CLI use
Method string // session, token, local
IP string
Agent string
}
// System is the principal used for internal operations.
var System = &Principal{User: &model.User{ID: 0, Username: "system", Admin: true}, Method: "local"}
// Name returns a display name for the principal.
func (p *Principal) Name() string {
if p == nil || p.User == nil {
return ""
}
return p.User.Username
}
// IsAdmin reports whether the principal has admin rights. Admin rights
// are never available through a restricted token.
func (p *Principal) IsAdmin() bool {
if p == nil || p.User == nil || !p.User.Admin {
return false
}
return p.Token == nil || p.Token.Grants == nil
}
// Can reports whether the principal may perform a.
func (p *Principal) Can(a Action) bool {
if p == nil || p.User == nil || p.User.Disabled {
return false
}
userOK := p.User.Admin || GrantsAllow(p.User.Grants, a)
if !userOK {
return false
}
if p.Token != nil && p.Token.Grants != nil {
return GrantsAllow(p.Token.Grants, a)
}
return true
}
// CanSeeZone reports whether the principal may see that a zone exists:
// any read access to the zone definition or to some of its records.
func (p *Principal) CanSeeZone(zone, view string) bool {
if p.Can(Action{Resource: ResZones, Zone: zone, View: view}) {
return true
}
// Records access with name/type filters still reveals the zone.
check := func(gs []model.Grant) bool {
for _, g := range gs {
if g.Resource != ResAll && g.Resource != ResRecords && g.Resource != ResZones {
continue
}
ng := g
ng.Names, ng.Types = nil, nil
if GrantAllows(ng, Action{Resource: g.Resource, Zone: zone, View: view}) {
return true
}
}
return false
}
if p.User == nil || p.User.Disabled {
return false
}
if !p.User.Admin && !check(p.User.Grants) {
return false
}
if p.Token != nil && p.Token.Grants != nil {
return check(p.Token.Grants)
}
return true
}
// Summary describes effective permissions for display.
func (p *Principal) Summary() []string {
if p.IsAdmin() {
return []string{"admin (everything)"}
}
var out []string
gs := p.User.Grants
if p.User.Admin {
gs = []model.Grant{{Resource: ResAll, Access: Write}}
}
for _, g := range gs {
out = append(out, FormatGrant(g))
}
if p.Token != nil && p.Token.Grants != nil {
var tg []string
for _, g := range p.Token.Grants {
tg = append(tg, FormatGrant(g))
}
out = append(out, "restricted by token to: "+strings.Join(tg, "; "))
}
sort.Strings(out)
return out
}
// RoleTemplates are ready-made grant sets offered in the UI and CLI.
var RoleTemplates = map[string][]model.Grant{
"readonly": {{Resource: ResAll, Access: Read}},
"dns-editor": {
{Resource: ResZones, Access: Write},
{Resource: ResRecords, Access: Write},
{Resource: ResViews, Access: Read},
{Resource: ResACLs, Access: Read},
{Resource: ResOptions, Access: Read},
{Resource: ResBlocking, Access: Read},
},
"operator": {{Resource: ResAll, Access: Write}},
"acme": {{Resource: ResRecords, Access: Write, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}},
}