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 // ResCerts is certificates for other hosts (jarl cert get), by the // names in them. It is never part of "*": it hands out private keys. ResCerts = "certificates" ResAll = "*" ) // Resources lists the resources in display order. var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking, ResCerts} // 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.Types) > 0 && g.Resource != ResRecords { return fmt.Errorf("the types filter only applies to the records resource") } if len(g.Names) > 0 && g.Resource != ResRecords && g.Resource != ResCerts { return fmt.Errorf("the names filter only applies to the records and certificates resources") } 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 g.Resource == ResAll && a.Resource == ResCerts { return false // only an explicit certificates grant } 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"}}}, // certificates for other hosts (jarl cert get): narrow it with names= // to the host's own. "certificates": {{Resource: ResCerts, Access: Write}}, }