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>
386 lines
9.8 KiB
Go
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"}}},
|
|
}
|