From e98a25d1f6d44e0ad2b44ab08dd7a3e47d9e33de Mon Sep 17 00:00:00 2001 From: ale <ale@incal.net> Date: Wed, 1 Apr 2020 08:43:04 +0100 Subject: [PATCH] Shelve off temporary changes to a separate branch --- backend/ldap/model.go | 17 + service.go | 1 + template.go | 225 +++++++++++ validation_helpers.go | 126 ++++++ validators.go | 915 ++++++++++++++++++++---------------------- 5 files changed, 797 insertions(+), 487 deletions(-) create mode 100644 template.go create mode 100644 validation_helpers.go diff --git a/backend/ldap/model.go b/backend/ldap/model.go index ec54abb3..a0f4ae1a 100644 --- a/backend/ldap/model.go +++ b/backend/ldap/model.go @@ -3,6 +3,7 @@ package ldapbackend import ( "bytes" "context" + "errors" "fmt" "math/rand" "strconv" @@ -525,6 +526,22 @@ func (tx *backendTX) searchResourcesByType(ctx context.Context, pattern, resourc return out, nil } +// FindResource fetches a specific resource by type and name. +func (tx *backendTX) FindResource(ctx context.Context, spec as.FindResourceRequest) (*as.RawResource, error) { + result, err := tx.searchResourcesByType(ctx, spec.Name, spec.Type) + if err != nil { + return nil, err + } + switch len(result) { + case 0: + return nil, nil + case 1: + return result[0], nil + default: + return nil, errors.New("too many results") + } +} + // SearchResource returns all the resources matching the pattern. func (tx *backendTX) SearchResource(ctx context.Context, pattern string) ([]*as.RawResource, error) { // Aggregate results for all known resource types. diff --git a/service.go b/service.go index d2d02d2f..450c51e4 100644 --- a/service.go +++ b/service.go @@ -51,6 +51,7 @@ type TX interface { UpdateResource(context.Context, *Resource) error CreateResources(context.Context, *User, []*Resource) ([]*Resource, error) SetResourcePassword(context.Context, *Resource, string) error + FindResource(context.Context, FindResourceRequest) (*RawResource, error) HasAnyResource(context.Context, []FindResourceRequest) (bool, error) GetUser(context.Context, string) (*RawUser, error) diff --git a/template.go b/template.go new file mode 100644 index 00000000..a9d5b33d --- /dev/null +++ b/template.go @@ -0,0 +1,225 @@ +package accountserver + +import ( + "context" + "errors" + "fmt" + "math/rand" + "path/filepath" + "strings" + "time" +) + +// Templating, in this context, means filling up derived attributes in a resource. +// +// Derived attributes are always enforced (i.e. we don't check if the +// attribute already has a value), but currently this is only done at +// resource creation time. The plan is to extend this to cover any +// Update call as well. +type templateContext struct { + shards shardBackend + webroot string +} + +func (c *templateContext) pickShard(ctx context.Context, r *Resource) (string, error) { + avail := c.shards.GetAvailableShards(ctx, r.Type) + if len(avail) == 0 { + return "", fmt.Errorf("no available shards for resource type %s", r.Type) + } + return avail[rand.Intn(len(avail))], nil +} + +func (c *templateContext) setResourceShard(ctx context.Context, r *Resource, ref *Resource) error { + if r.Shard == "" { + if ref != nil { + r.Shard = ref.Shard + } else { + s, err := c.pickShard(ctx, r) + if err != nil { + return err + } + r.Shard = s + } + } + if r.OriginalShard == "" { + r.OriginalShard = r.Shard + } + return nil +} + +func (c *templateContext) setResourceStatus(r *Resource) { + if r.Status == "" { + r.Status = ResourceStatusActive + } +} + +func (c *templateContext) setCommonResourceAttrs(ctx context.Context, r *Resource, ref *Resource, user *User) error { + // If we reference another resource, ensure it has been templated. + if ref != nil { + if err := c.applyTemplate(ctx, ref, user); err != nil { + return err + } + } + + r.CreatedAt = time.Now().UTC().Format("2006-01-02") + + c.setResourceStatus(r) + return c.setResourceShard(ctx, r, ref) +} + +// Apply default values to an Email resource. +func (c *templateContext) emailResourceTemplate(ctx context.Context, r *Resource, _ *User) error { + // Force the email address to lowercase. + r.Name = strings.ToLower(r.Name) + + if r.Email == nil { + r.Email = new(Email) + } + + addrParts := strings.Split(r.Name, "@") + if len(addrParts) != 2 { + return errors.New("malformed name") + } + r.Email.Maildir = fmt.Sprintf("%s/%s", addrParts[1], addrParts[0]) + r.Email.QuotaLimit = 4096 + return c.setCommonResourceAttrs(ctx, r, nil, nil) +} + +// Apply default values to a Website or Domain resource. +func (c *templateContext) websiteResourceTemplate(ctx context.Context, r *Resource, user *User) error { + if user == nil { + return errors.New("website resource needs owner") + } + + // Force the website address to lowercase. + r.Name = strings.ToLower(r.Name) + + if r.Website == nil { + r.Website = new(Website) + } + + // If the client did not specify a DocumentRoot, find a DAV resource + // and associate the website with it. + if r.Website.DocumentRoot == "" { + dav := user.GetSingleResourceByType(ResourceTypeDAV) + if dav == nil { + return errors.New("user has no DAV accounts") + } + + // The DAV resource may not have been templatized yet. + if dav.DAV == nil || dav.DAV.Homedir == "" { + if err := c.davResourceTemplate(ctx, dav, user); err != nil { + return err + } + } + r.Website.DocumentRoot = filepath.Join(dav.DAV.Homedir, "html-"+r.Name) + } + r.Website.DocumentRoot = filepath.Clean(r.Website.DocumentRoot) + + if len(r.Website.Options) == 0 { + r.Website.Options = []string{"nomail"} + } + + r.Website.UID = user.UID + + dav := findMatchingDAVAccount(user, r) + if dav == nil { + return fmt.Errorf("no DAV resources matching website %s", r.String()) + } + return c.setCommonResourceAttrs(ctx, r, dav, user) +} + +// Apply default values to a DAV resource. +func (c *templateContext) davResourceTemplate(ctx context.Context, r *Resource, user *User) error { + if user == nil { + return errors.New("dav resource needs owner") + } + + // Force the account name to lowercase. + r.Name = strings.ToLower(r.Name) + + if r.DAV == nil { + r.DAV = new(WebDAV) + } + if r.DAV.Homedir == "" { + r.DAV.Homedir = filepath.Join(c.webroot, r.Name) + } + r.DAV.Homedir = filepath.Clean(r.DAV.Homedir) + r.DAV.UID = user.UID + + return c.setCommonResourceAttrs(ctx, r, nil, user) +} + +// Apply default values to a Database resource. +func (c *templateContext) databaseResourceTemplate(ctx context.Context, r *Resource, user *User) error { + if user == nil { + return errors.New("database resource needs owner") + } + + // Force the database name to lowercase. + r.Name = strings.ToLower(r.Name) + + if r.Database == nil { + r.Database = new(Database) + } + if r.Database.DBUser == "" { + r.Database.DBUser = r.Name + } + + return c.setCommonResourceAttrs(ctx, r, user.GetResourceByID(r.ParentID), user) +} + +// Apply default values to a MailingList resource. +func (c *templateContext) listResourceTemplate(ctx context.Context, r *Resource, user *User) error { + // Force the list address to lowercase. + r.Name = strings.ToLower(r.Name) + + if r.List == nil { + r.List = new(MailingList) + } + + // As a convenience, if a user is passed in the context, we add it to + // the list admins. + if user != nil && len(r.List.Admins) == 0 { + r.List.Admins = []string{user.Name} + } + + return c.setCommonResourceAttrs(ctx, r, nil, nil) +} + +// Apply default values to a Newsletter resource. +func (c *templateContext) newsletterResourceTemplate(ctx context.Context, r *Resource, user *User) error { + // Force the list address to lowercase. + r.Name = strings.ToLower(r.Name) + + if r.Newsletter == nil { + r.Newsletter = new(Newsletter) + } + + // As a convenience, if a user is passed in the context, we add it to + // the list admins. + if user != nil && len(r.Newsletter.Admins) == 0 { + r.Newsletter.Admins = []string{user.Name} + } + + return c.setCommonResourceAttrs(ctx, r, nil, nil) +} + +// Apply default values to a resource. +func (c *templateContext) applyTemplate(ctx context.Context, r *Resource, user *User) error { + switch r.Type { + case ResourceTypeEmail: + return c.emailResourceTemplate(ctx, r, user) + case ResourceTypeWebsite, ResourceTypeDomain: + return c.websiteResourceTemplate(ctx, r, user) + case ResourceTypeDAV: + return c.davResourceTemplate(ctx, r, user) + case ResourceTypeDatabase: + return c.databaseResourceTemplate(ctx, r, user) + case ResourceTypeMailingList: + return c.listResourceTemplate(ctx, r, user) + case ResourceTypeNewsletter: + return c.newsletterResourceTemplate(ctx, r, user) + } + return nil +} diff --git a/validation_helpers.go b/validation_helpers.go new file mode 100644 index 00000000..b278f6ea --- /dev/null +++ b/validation_helpers.go @@ -0,0 +1,126 @@ +package accountserver + +import ( + "bufio" + "context" + "os" + "strings" +) + +// Read non-empty lines from a file, ignoring comments and +// leading/trailing whitespace. +func loadStringListFromFile(path string) ([]string, error) { + f, err := os.Open(path) // #nosec + if err != nil { + return nil, err + } + defer f.Close() // nolint: errcheck + + var list []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if n := strings.Index(line, "#"); n >= 0 { + line = line[:n] + } + line = strings.TrimSpace(line) + if line != "" { + list = append(list, line) + } + } + return list, scanner.Err() +} + +// A stringSet is just a list of strings with a quick membership test. +type stringSet struct { + set map[string]struct{} + list []string +} + +func newStringSetFromList(list []string) *stringSet { + set := make(map[string]struct{}) + for _, s := range list { + set[s] = struct{}{} + } + return &stringSet{set: set, list: list} +} + +func newStringSetFromFileAndList(list []string, path string) (*stringSet, error) { + if path != "" { + more, err := loadStringListFromFile(path) + if err != nil { + return nil, err + } + list = append(list, more...) + } + return newStringSetFromList(list), nil +} + +func (s stringSet) Contains(needle string) bool { + _, ok := s.set[needle] + return ok +} + +func (s stringSet) Check(_ *RequestContext, value string) bool { + _, ok := s.set[value] + return ok +} + +func (s stringSet) List(_ context.Context) []string { + return s.list +} + +// List of email domains backed by our database (with ACLs). +type emailDomainsDataset struct { + backend Backend +} + +func (s *emailDomainsDataset) Check(rctx *RequestContext, value string) bool { + tx, err := s.backend.NewTransaction() + if err != nil { + // Fail close on database errors. + return false + } + + // Ignore the error in FindResource() since we're going to fail close anyway. + // nolint: errcheck + rsrc, _ := tx.FindResource(rctx.Context, FindResourceRequest{Type: ResourceTypeDomain, Name: value}) + if rsrc == nil { + return false + } + + // Only allow domains that have acceptMail=true IF the + // requesting user is an admin. + // + // TODO: relax the ACL check allowing domain owners to create + // accounts? + if rsrc.Website.AcceptMail && rctx.Auth.IsAdmin { + return true + } + + return false +} + +func (s *emailDomainsDataset) List(_ context.Context) []string { return nil } + +// Combine multiple datasets. +type multiDataset struct { + datasets []validationDataset +} + +func (m *multiDataset) Check(rctx *RequestContext, value string) bool { + for _, d := range m.datasets { + if d.Check(rctx, value) { + return true + } + } + return false +} + +func (m *multiDataset) List(ctx context.Context) []string { + var out []string + for _, d := range m.datasets { + out = append(out, d.List(ctx)...) + } + return out +} diff --git a/validators.go b/validators.go index 4b6c8fb4..8995ef9e 100644 --- a/validators.go +++ b/validators.go @@ -1,31 +1,53 @@ package accountserver import ( - "bufio" "context" "errors" "fmt" - "math/rand" - "os" - "path/filepath" "regexp" "strings" - "time" "golang.org/x/net/publicsuffix" ) -// A domainBackend manages the list of domains users are allowed to request services on. -type domainBackend interface { - GetAllowedDomains(context.Context, string) []string - IsAllowedDomain(context.Context, string, string) bool +// Validation and templating require external data providers, lists of +// domains etc, that might be provided by static files or dynamic +// (database-backed) sources, represented by the following interfaces. +type validationDataset interface { + // Check if a value is contained in the dataset. Since the + // dataset can be dynamic we pass the full RequestContext + // (which includes authentication information). + Check(*RequestContext, string) bool + + // List all values in the dataset. Mostly used for debugging + // purposes. + List(context.Context) []string +} + +// Some data sources are keyed by resource type, the following helper +// type keeps the code more readable. +type validationDatasetMap map[string]validationDataset + +func newValidationDatasetMap(m map[string][]string) validationDatasetMap { + v := make(validationDatasetMap) + for key, values := range m { + v[key] = newStringSetFromList(values) + } + return v +} + +func (m validationDatasetMap) Check(rctx *RequestContext, key, value string) bool { + if v, ok := m[key]; ok { + return v.Check(rctx, value) + } + return false } -// A shardBackend can return information about available / allowed service shards. -type shardBackend interface { - GetAllowedShards(context.Context, string) []string - GetAvailableShards(context.Context, string) []string - IsAllowedShard(context.Context, string, string) bool +func (m validationDatasetMap) List(ctx context.Context, key string) []string { + if v, ok := m[key]; ok { + return v.List(ctx) + } + return nil } // ValidationConfig specifies a large number of validation-related @@ -45,10 +67,6 @@ type ValidationConfig struct { MaxUsernameLen int `yaml:"max_username_len"` MinUID int `yaml:"min_backend_uid"` MaxUID int `yaml:"max_backend_uid"` - - forbiddenUsernames stringSet - forbiddenPasswords stringSet - forbiddenDomains stringSet } const ( @@ -81,121 +99,38 @@ func (c *ValidationConfig) setDefaults() { } } -func (c *ValidationConfig) compile() (err error) { - c.setDefaults() - - c.forbiddenUsernames, err = newStringSetFromFileOrList(c.ForbiddenUsernames, c.ForbiddenUsernamesFile) - if err != nil { - return - } - c.forbiddenPasswords, err = newStringSetFromFileOrList(c.ForbiddenPasswords, c.ForbiddenPasswordsFile) - if err != nil { - return - } - c.forbiddenDomains, err = newStringSetFromFileOrList(c.ForbiddenDomains, c.ForbiddenDomainsFile) - return -} - // The validationContext contains all configuration and backends that // the various validation functions will need. Most methods on this // object return functions themselves (ValidatorFunc or variations // thereof) that can later be called multiple times at will and // combined with operators like 'allOf'. type validationContext struct { - config *ValidationConfig - domains domainBackend - shards shardBackend - backend Backend -} - -// A stringSet is just a list of strings with a quick membership test. -type stringSet struct { - set map[string]struct{} - list []string -} - -func newStringSetFromList(list []string) stringSet { - set := make(map[string]struct{}) - for _, s := range list { - set[s] = struct{}{} - } - return stringSet{set: set, list: list} -} - -func newStringSetFromFileOrList(list []string, path string) (stringSet, error) { - if path != "" { - return loadStringSetFromFile(path) - } - return newStringSetFromList(list), nil + config *ValidationConfig + availableDomains validationDataset + allowedDomains validationDataset + availableShards validationDataset + allowedShards validationDataset + forbiddenUsernames validationDataset + forbiddenPasswords validationDataset + forbiddenDomains validationDataset + backend Backend } -func (s stringSet) Contains(needle string) bool { - _, ok := s.set[needle] - return ok -} - -func (s stringSet) List() []string { - return s.list -} - -// A domainBackend that works with a static list of type-specific allowed domains. -type staticDomainBackend struct { - sets map[string]stringSet -} - -func (d *staticDomainBackend) GetAllowedDomains(_ context.Context, kind string) []string { - return d.sets[kind].List() -} - -func (d *staticDomainBackend) IsAllowedDomain(_ context.Context, kind, domain string) bool { - return d.sets[kind].Contains(domain) -} - -type staticShardBackend struct { - available map[string]stringSet - allowed map[string]stringSet -} - -func (d *staticShardBackend) GetAllowedShards(_ context.Context, kind string) []string { - return d.allowed[kind].List() -} - -func (d *staticShardBackend) GetAvailableShards(_ context.Context, kind string) []string { - return d.available[kind].List() -} - -func (d *staticShardBackend) IsAllowedShard(_ context.Context, kind, shard string) bool { - return d.allowed[kind].Contains(shard) -} - -func loadStringSetFromFile(path string) (stringSet, error) { - f, err := os.Open(path) // #nosec - if err != nil { - return stringSet{}, err - } - defer f.Close() // nolint: errcheck +// ValidatorFunc is the generic interface for unstructured data field +// (string) validators. +type ValidatorFunc func(*RequestContext, string) error - var list []string - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue +func inSet(set stringSet) ValidatorFunc { + return func(_ *RequestContext, value string) error { + if !set.Contains(value) { + return errors.New("value not allowed") } - list = append(list, line) - } - if err := scanner.Err(); err != nil { - return stringSet{}, err + return nil } - return newStringSetFromList(list), nil } -// ValidatorFunc is the generic interface for unstructured data field -// (string) validators. -type ValidatorFunc func(context.Context, string) error - func notInSet(set stringSet) ValidatorFunc { - return func(_ context.Context, value string) error { + return func(_ *RequestContext, value string) error { if set.Contains(value) { return errors.New("invalid value (blacklisted)") } @@ -204,7 +139,7 @@ func notInSet(set stringSet) ValidatorFunc { } func minLength(minLen int) ValidatorFunc { - return func(_ context.Context, value string) error { + return func(_ *RequestContext, value string) error { if len(value) < minLen { return fmt.Errorf("value must be at least %d characters", minLen) } @@ -213,7 +148,7 @@ func minLength(minLen int) ValidatorFunc { } func maxLength(maxLen int) ValidatorFunc { - return func(_ context.Context, value string) error { + return func(_ *RequestContext, value string) error { if len(value) > maxLen { return fmt.Errorf("value must be at most %d characters", maxLen) } @@ -222,7 +157,7 @@ func maxLength(maxLen int) ValidatorFunc { } func matchRegexp(rx *regexp.Regexp, errmsg string) ValidatorFunc { - return func(_ context.Context, value string) error { + return func(_ *RequestContext, value string) error { if !rx.MatchString(value) { return errors.New(errmsg) } @@ -248,16 +183,29 @@ func matchIdentifierRx() ValidatorFunc { return matchRegexp(identifierRx, "value must be alphanumeric") } +// Validator that returns true only if all the arguments return true. +func allOf(funcs ...ValidatorFunc) ValidatorFunc { + return func(rctx *RequestContext, value string) error { + for _, f := range funcs { + if err := f(rctx, value); err != nil { + return err + } + } + return nil + } +} + +// Separately validate username and domain parts of an email address. func validateUsernameAndDomain(validateUsername, validateDomain ValidatorFunc) ValidatorFunc { - return func(ctx context.Context, value string) error { + return func(rctx *RequestContext, value string) error { parts := strings.SplitN(value, "@", 2) if len(parts) != 2 { return errors.New("malformed email address") } - if err := validateUsername(ctx, parts[0]); err != nil { + if err := validateUsername(rctx, parts[0]); err != nil { return err } - return validateDomain(ctx, parts[1]) + return validateDomain(rctx, parts[1]) } } @@ -270,7 +218,7 @@ func isRegistered(domain string) bool { return true } -func validDomainName(_ context.Context, value string) error { +func validDomainName(_ *RequestContext, value string) error { if !domainRx.MatchString(value) { return errors.New("invalid domain name") } @@ -288,30 +236,41 @@ func validDomainName(_ context.Context, value string) error { return nil } -// Split a (correctly formed) email address into username/domain. -func splitEmailAddr(addr string) (string, string) { +// Split an email address into username and domain. The address should +// be valid or we will panic. +func splitAddr(addr string) (string, string) { parts := strings.SplitN(addr, "@", 2) return parts[0], parts[1] } // Returns all the possible resources in the email and mailing list // namespaces that might conflict with the given email address. -func relatedEmails(ctx context.Context, be domainBackend, addr string) []FindResourceRequest { +func relatedEmails(ctx context.Context, addr string) []FindResourceRequest { + return []FindResourceRequest{ + {Type: ResourceTypeEmail, Name: addr}, + {Type: ResourceTypeMailingList, Name: addr}, + {Type: ResourceTypeNewsletter, Name: addr}, + } +} + +func relatedMailingLists(ctx context.Context, domains validationDatasetMap, addr string) []FindResourceRequest { + // Check for an email with the same address. rel := []FindResourceRequest{ {Type: ResourceTypeEmail, Name: addr}, } - user, _ := splitEmailAddr(addr) + // Mailing lists and newsletters must have unique names // regardless of the domain, so we add potential conflicts for // mailing lists with the same name over all list-enabled // domains. - for _, d := range be.GetAllowedDomains(ctx, ResourceTypeMailingList) { + user, _ := splitAddr(addr) + for _, d := range domains.List(ctx, ResourceTypeMailingList) { rel = append(rel, FindResourceRequest{ Type: ResourceTypeMailingList, Name: fmt.Sprintf("%s@%s", user, d), }) } - for _, d := range be.GetAllowedDomains(ctx, ResourceTypeNewsletter) { + for _, d := range domains.List(ctx, ResourceTypeNewsletter) { rel = append(rel, FindResourceRequest{ Type: ResourceTypeNewsletter, Name: fmt.Sprintf("%s@%s", user, d), @@ -323,19 +282,13 @@ func relatedEmails(ctx context.Context, be domainBackend, addr string) []FindRes func relatedWebsites(ctx context.Context, be domainBackend, value string) []FindResourceRequest { // Ignore the parent domain (websites share a global namespace). return []FindResourceRequest{ - { - Type: ResourceTypeWebsite, - Name: value, - }, + {Type: ResourceTypeWebsite, Name: value}, } } func relatedDomains(ctx context.Context, be domainBackend, value string) []FindResourceRequest { return []FindResourceRequest{ - { - Type: ResourceTypeDomain, - Name: value, - }, + {Type: ResourceTypeDomain, Name: value}, } } @@ -348,58 +301,14 @@ func (v *validationContext) isAllowedDomain(rtype string) ValidatorFunc { } } -func (v *validationContext) isAvailableEmailAddr() ValidatorFunc { - return func(ctx context.Context, value string) error { - rel := relatedEmails(ctx, v.domains, value) - - // Run the presence check in a new transaction. Unavailability - // of the server results in a validation error (fail close). - tx, err := v.backend.NewTransaction() - if err != nil { - return err - } - // Errors will cause to consider the address unavailable. - if ok, _ := tx.HasAnyResource(ctx, rel); ok { // nolint - return errors.New("address unavailable") - } - return nil - } -} - -func (v *validationContext) isAvailableDomain() ValidatorFunc { - return func(ctx context.Context, value string) error { - rel := relatedDomains(ctx, v.domains, value) - - // Run the presence check in a new transaction. Unavailability - // of the server results in a validation error (fail close). - tx, err := v.backend.NewTransaction() - if err != nil { - return err - } - // Errors will cause to consider the resource unavailable. - if ok, _ := tx.HasAnyResource(ctx, rel); ok { // nolint - return errors.New("address unavailable") - } - return nil - } -} - -func (v *validationContext) isAvailableWebsite() ValidatorFunc { - return func(ctx context.Context, value string) error { - rel := relatedWebsites(ctx, v.domains, value) - - // Run the presence check in a new transaction. Unavailability - // of the server results in a validation error (fail close). - tx, err := v.backend.NewTransaction() - if err != nil { - return err - } - // Errors will cause to consider the resource unavailable. - if ok, _ := tx.HasAnyResource(ctx, rel); ok { // nolint - return errors.New("address unavailable") - } - return nil +func isResourceAvailable(ctx context.Context, backend Backend, rsrcs []FindResourceRequest) bool { + tx, err := backend.NewTransaction() + if err != nil { + // Fail close, return "not available". + return false } + ok, _ := tx.HasAnyResource(ctx, rsrcs) + return !ok } func (v *validationContext) isAvailableDAV() ValidatorFunc { @@ -487,22 +396,22 @@ func (v *validationContext) validPassword() ValidatorFunc { ) } -func allOf(funcs ...ValidatorFunc) ValidatorFunc { - return func(ctx context.Context, value string) error { - for _, f := range funcs { - if err := f(ctx, value); err != nil { - return err - } - } - return nil - } +// ResourceValidator is a composite type validator that checks +// various fields in a Resource, depending on its type. +// +// The availability check is separate and it performs the role of an +// ACL check. +type resourceValidator interface { + Validate(*RequestContext, *Resource, *User) error + IsAvailable(*RequestContext, *Resource, *User) bool } -// ResourceValidatorFunc is a composite type validator that checks -// various fields in a Resource, depending on its type. -type ResourceValidatorFunc func(context.Context, *Resource, *User, bool) error +type resourceValidatorBase struct { + backend Backend +} -func (v *validationContext) validateResource(_ context.Context, r *Resource, user *User) error { +// Validate the common attributes of a Resource. +func (v *resourceValidatorBase) Validate(_ *RequestContext, r *Resource, user *User) error { // Validate the status enum. switch r.Status { case ResourceStatusActive, ResourceStatusInactive, ResourceStatusReadonly, ResourceStatusArchived: @@ -524,19 +433,40 @@ func (v *validationContext) validateResource(_ context.Context, r *Resource, use return nil } -func (v *validationContext) validateShardedResource(ctx context.Context, r *Resource, user *User) error { - if err := v.validateResource(ctx, r, user); err != nil { +// Helper function to check availability of one or more resources in +// the database. We accept a list of resources as input because the +// name spaces for different resources partially overlap (emails, +// mailing lists, etc) so we often have to check multiple types. +func (v *resourceValidatorBase) checkResources(ctx context.Context, rsrcs []FindResourcesRequest) bool { + tx, err := v.backend.NewTransaction() + if err != nil { + // Fail close on database errors. + return false + } + ok, _ := tx.HasAnyResource(ctx, rsrcs) + return ok +} + +type shardedResourceValidatorBase struct { + *resourceValidatorBase + allowedShards validationDatasetMap +} + +// Validate the attributes of a sharded resource. +func (v *shardedResourceValidatorBase) Validate(rctx *ResourceContext, r *Resource, user *User) error { + if err := v.resourceValidatorBase.Validate(rctx, r, user); err != nil { return err } + if r.Shard == "" { return errors.New("empty shard") } - if !v.shards.IsAllowedShard(ctx, r.Type, r.Shard) { + if !allowedShards.Check(rctx, r.Type, r.Shard) { return fmt.Errorf( "invalid shard %s for resource type %s (allowed: %v)", r.Shard, r.Type, - v.shards.GetAllowedShards(ctx, r.Type), + allowedShards.List(rctx.Context(), r.Type), ) } if r.OriginalShard == "" { @@ -545,99 +475,318 @@ func (v *validationContext) validateShardedResource(ctx context.Context, r *Reso return nil } -func (v *validationContext) validEmailResource() ResourceValidatorFunc { - emailValidator := v.validHostedEmail() - newEmailValidator := v.validHostedNewEmail() +// Validator for Email resources. +type emailResourceValidator struct { + *shardedResourceValidatorBase - return func(ctx context.Context, r *Resource, user *User, isNew bool) error { - if err := v.validateShardedResource(ctx, r, user); err != nil { - return err - } + maxUsernameLen int + minUsernameLen int + forbiddenUsernames validationDataset + allowedDomains validationDataset +} - // Email resources aren't nested. - if !r.ParentID.Empty() { - return errors.New("resource should not have parent") - } +func (v *emailResourceValidator) Validate(rctx *ResourceContext, r *Resource, user *User) error { + if err := v.shardedResourceValidatorBase.Validate(rctx, r, user); err != nil { + return err + } - if r.Email == nil { - return errors.New("resource has no email metadata") - } + // Email resources shouldn't be nested. + if !r.ParentID.Empty() { + return errors.New("resource should not have parent") + } - var err error - if isNew { - err = newEmailValidator(ctx, r.Name) - } else { - err = emailValidator(ctx, r.Name) - } - if err != nil { + if r.Email == nil { + return errors.New("resource has no email metadata") + } + if r.Email.Maildir == "" { + return errors.New("empty maildir") + } + + // Validate the resource email address field, and check for + // availability of the domain. + return validateUsernameAndDomain( + allOf( + matchUsernameRx(), + minLength(v.minUsernameLen), + maxLength(v.maxUsernameLen), + notInSet(v.forbiddenUsernames), + ), + allOf( + inSet(v.allowedDomains), + ), + )(rtcx, r.Name) +} + +func (v *emailResourceValidator) IsAvailable(rctx *ResourceContext, r *Resource, user *User) bool { + // Emails share the same namespace as mailing lists and newsletters. + addr := r.Name + return v.checkResources([]FindResourceRequest{ + {Type: ResourceTypeEmail, Name: addr}, + {Type: ResourceTypeMailingList, Name: addr}, + {Type: ResourceTypeNewsletter, Name: addr}, + }) +} + +// Mailing lists and newsletters share the same validation logic. +type listResourceValidatorBase struct { + *shardedResourceValidatorBase +} + +func (v *listResourceValidatorBase) Validate(rctx *RequestContext, r *Resource, user *User) error { + if err := v.shardedResourceValidatorBase.Validate(rctx, r, user); err != nil { + return err + } + + // List resources shouldn't be nested. + if !r.ParentID.Empty() { + return errors.New("resource should not have parent") + } + + // Validate the list name. + return validateUsernameAndDomain( + allOf( + matchUsernameRx(), + minLength(v.minUsernameLen), + maxLength(v.maxUsernameLen), + notInSet(v.forbiddenUsernames), + ), + inSet(v.allowedDomains), + )(rctx, r.Name) +} + +// Helper to validate a list of admins. +func (v *listResourceValidatorBase) validateAdmins(rctx *RequestContext, admins []string) error { + if len(admins) < 1 { + return errors.New("list has no admins") + } + for _, addr := range admins { + if err := validateEmailAddr(rctx, addr); err != nil { return err } + } + return nil +} - if r.Email.Maildir == "" { - return errors.New("empty maildir") - } - return nil +func (v *listResourceValidatorBase) IsAvailable(rctx *ResourceContext, r *Resource, _ *User) bool { + // Check for an email with the same address. + rel := []FindResourceRequest{ + {Type: ResourceTypeEmail, Name: r.Name}, + } + + // Mailing lists and newsletters must have unique names + // regardless of the domain, so we add potential conflicts for + // mailing lists with the same name over all list-enabled + // domains. + user, _ := splitAddr(r.Name) + for _, d := range domains.List(ctx, ResourceTypeMailingList) { + rel = append(rel, FindResourceRequest{ + Type: ResourceTypeMailingList, + Name: fmt.Sprintf("%s@%s", user, d), + }) + } + for _, d := range domains.List(ctx, ResourceTypeNewsletter) { + rel = append(rel, FindResourceRequest{ + Type: ResourceTypeNewsletter, + Name: fmt.Sprintf("%s@%s", user, d), + }) } + return v.checkResources(rel) } -func (v *validationContext) validListResource() ResourceValidatorFunc { - listValidator := v.validHostedMailingList() - newListValidator := allOf(listValidator, v.isAvailableEmailAddr()) +// Validator for MailingList resources. +type mailingListResourceValidator struct { + *listResourceValidatorBase +} - return func(ctx context.Context, r *Resource, user *User, isNew bool) error { - if err := v.validateShardedResource(ctx, r, user); err != nil { - return err - } - if r.List == nil { - return errors.New("resource has no list metadata") - } +func (v *mailingListResourceValidator) Validate(rctx *RequestContext, r *Resource, user *User) error { + if err := v.listResourceValidatorBase.Validate(rctx, r, user); err != nil { + return err + } - var err error - if isNew { - err = newListValidator(ctx, r.Name) - } else { - err = listValidator(ctx, r.Name) - } - if err != nil { - return err - } + if r.List == nil { + return errors.New("resource has no list metadata") + } + return v.validateAdmins(rctx, r.List.Admins) +} - if len(r.List.Admins) < 1 { - return errors.New("can't create a list without admins") - } - return nil +// Validator for Newsletter resources. +type newsletterResourceValidator struct { + *listResourceValidatorBase +} + +func (v *newsletterResourceValidator) Validate(rctx *RequestContext, r *Resource, user *User) error { + if err := v.listResourceValidatorBase.Validate(rctx, r, user); err != nil { + return err } + + if r.Newsletter == nil { + return errors.New("resource has no newsletter metadata") + } + return r.validateAdmins(rctx, r.Newsletter.Admins) } -func (v *validationContext) validNewsletterResource() ResourceValidatorFunc { - listValidator := v.validHostedMailingList() - newListValidator := allOf(listValidator, v.isAvailableEmailAddr()) +// Validator helper type for resources that have a UNIX user ID. +type uidResourceValidatorMixin struct { + minUID, maxUID int +} - return func(ctx context.Context, r *Resource, user *User, isNew bool) error { - if err := v.validateShardedResource(ctx, r, user); err != nil { - return err - } - if r.Newsletter == nil { - return errors.New("resource has no newsletter metadata") - } +func (v *uidResourceValidatorMixin) validateUID(uid int, user *User) error { + if uid == 0 { + return errors.New("resource UID is not set") + } + if uid < v.minUID || (v.maxUID > 0 && uid > v.maxUID) { + return fmt.Errorf("resource UID %d outside of allowed range (%d-%d)", uid, v.minUID, v.maxUID) + } + if user != nil && uid != user.UID { + return errors.New("resource UID differs from user UID") + } + return nil +} - var err error - if isNew { - err = newListValidator(ctx, r.Name) - } else { - err = listValidator(ctx, r.Name) - } - if err != nil { - return err - } +// Base validator type for web resources. +type webResourceValidatorBase struct { + *shardedResourceValidatorBase + *uidResourceValidatorMixin - if len(r.Newsletter.Admins) < 1 { - return errors.New("can't create a newsletter without admins") - } - return nil + webroot string +} + +func (v *webResourceValidatorBase) Validate(rctx *RequestContext, r *Resource, user *User) error { + if err := v.shardedResourceValidatorBase.Validate(rctx, r, user); err != nil { + return err + } + + // Web resources shouldn't be nested. + if !r.ParentID.Empty() { + return errors.New("resource should not have parent") + } + + if r.Website == nil { + return errors.New("resource has no website metadata") + } + + // Document root checks: must not be empty, and the + // user must have a DAV account with a home directory + // that is a parent of this document root. + if r.Website.DocumentRoot == "" { + return errors.New("empty document_root") + } + if !isSubdir(v.webroot, r.Website.DocumentRoot) { + return errors.New("document root outside of web root") + } + if !hasMatchingDAVAccount(user, r) { + return errors.New("website has no matching DAV account") } + + return v.uidResourceValidatorMixin.validateUID(r.Website.UID, user) +} + +type websiteResourceValidator struct { + *webResourceValidatorBase + + allowedDomains validatorDataset + forbiddenSiteNames validatorDataset + minNameLen int +} + +func (v *websiteResourceValidator) Validate(rctx *RequestContext, r *Resource, user *User) error { + if err := v.webResourceValidatorBase.Validate(rctx, r, user); err != nil { + return err + } + + if err := inSet(v.allowedDomains)(rctx, r.Website.ParentDomain); err != nil { + return err + } + if err := allOf( + matchSitenameRx(), + minLength(v.minNameLen), + notInSet(v.forbiddenSiteNames), + )(rctx, r.Name); err != nil { + return err + } + + return nil } +func (v *websiteResourceValidatorBase) IsAvailable(rctx *ResourceContext, r *Resource, _ *User) bool { +} + +type domainResourceValidator struct { + *webResourceValidatorBase + + forbiddenDomains validationDataset +} + +func (v *domainResourceValidator) Validate(rctx *RequestContext, r *Resource, user *User) error { + if err := v.webResourceValidatorBase.Validate(rctx, r, user); err != nil { + return err + } + + if r.Website.ParentDomain != "" { + return errors.New("non-empty parent_domain on domain resource") + } + + if err := allOf( + matchDomainRx(), + notInSet(v.forbiddenDomains), + )(rctx, r.Name); err != nil { + return err + } + + return nil +} + +func (v *domainResourceValidatorBase) IsAvailable(rctx *ResourceContext, r *Resource, _ *User) bool { +} + +type davResourceValidator struct { + *shardedResourceValidatorBase + *uidResourceValidatorMixin + + minLen int + webroot string +} + +func (v *davResourceValidator) Validate(rctx *RequestContext, r *Resource, user *User) error { + // return firstErr( + // v.shardedResourceValidatorBase.Validate(rctx, r, user), + // noParentID, + // notNil(r.DAV), + // validateField(r.Name, allOf( + // minLength(v.minLen), + // matchIdentifierRx(), + // )), + // subdirOf(v.webroot, r.DAV.Homedir), + // ) + + if err := v.shardedResourceValidatorBase.Validate(rctx, r, user); err != nil { + return err + } + + // DAV resources shouldn't be nested. + if !r.ParentID.Empty() { + return errors.New("resource should not have parent") + } + + if r.DAV == nil { + return errors.New("resource has no DAV metadata") + } + + if err := allOf( + minLength(v.minLen), + matchIdentifierRx(), + )(rctx, r.Name); err != nil { + return err + } + if !isSubdir(v.webroot, r.DAV.Homedir) { + return errors.New("homedir outside of web root") + } + + return v.uidResourceValidatorMixin.validateUID(r.DAV.UID, user) +} + +///////////////////// CUT THE CRAP HERE ///////////////////////// + func findMatchingDAVAccount(user *User, r *Resource) *Resource { for _, dav := range user.GetResourcesByType(ResourceTypeDAV) { if isSubdir(dav.DAV.Homedir, r.Website.DocumentRoot) { @@ -966,214 +1115,6 @@ func (v *validationContext) validUser() UserValidatorFunc { } } -type templateContext struct { - shards shardBackend - webroot string -} - -func (c *templateContext) pickShard(ctx context.Context, r *Resource) (string, error) { - avail := c.shards.GetAvailableShards(ctx, r.Type) - if len(avail) == 0 { - return "", fmt.Errorf("no available shards for resource type %s", r.Type) - } - return avail[rand.Intn(len(avail))], nil -} - -func (c *templateContext) setResourceShard(ctx context.Context, r *Resource, ref *Resource) error { - if r.Shard == "" { - if ref != nil { - r.Shard = ref.Shard - } else { - s, err := c.pickShard(ctx, r) - if err != nil { - return err - } - r.Shard = s - } - } - if r.OriginalShard == "" { - r.OriginalShard = r.Shard - } - return nil -} - -func (c *templateContext) setResourceStatus(r *Resource) { - if r.Status == "" { - r.Status = ResourceStatusActive - } -} - -func (c *templateContext) setCommonResourceAttrs(ctx context.Context, r *Resource, ref *Resource, user *User) error { - // If we reference another resource, ensure it has been templated. - if ref != nil { - if err := c.applyTemplate(ctx, ref, user); err != nil { - return err - } - } - - r.CreatedAt = time.Now().UTC().Format("2006-01-02") - - c.setResourceStatus(r) - return c.setResourceShard(ctx, r, ref) -} - -// Apply default values to an Email resource. -func (c *templateContext) emailResourceTemplate(ctx context.Context, r *Resource, _ *User) error { - // Force the email address to lowercase. - r.Name = strings.ToLower(r.Name) - - if r.Email == nil { - r.Email = new(Email) - } - - addrParts := strings.Split(r.Name, "@") - if len(addrParts) != 2 { - return errors.New("malformed name") - } - r.Email.Maildir = fmt.Sprintf("%s/%s", addrParts[1], addrParts[0]) - r.Email.QuotaLimit = 4096 - return c.setCommonResourceAttrs(ctx, r, nil, nil) -} - -// Apply default values to a Website or Domain resource. -func (c *templateContext) websiteResourceTemplate(ctx context.Context, r *Resource, user *User) error { - if user == nil { - return errors.New("website resource needs owner") - } - - // Force the website address to lowercase. - r.Name = strings.ToLower(r.Name) - - if r.Website == nil { - r.Website = new(Website) - } - - // If the client did not specify a DocumentRoot, find a DAV resource - // and associate the website with it. - if r.Website.DocumentRoot == "" { - dav := user.GetSingleResourceByType(ResourceTypeDAV) - if dav == nil { - return errors.New("user has no DAV accounts") - } - - // The DAV resource may not have been templatized yet. - if dav.DAV == nil || dav.DAV.Homedir == "" { - if err := c.davResourceTemplate(ctx, dav, user); err != nil { - return err - } - } - r.Website.DocumentRoot = filepath.Join(dav.DAV.Homedir, "html-"+r.Name) - } - r.Website.DocumentRoot = filepath.Clean(r.Website.DocumentRoot) - - if len(r.Website.Options) == 0 { - r.Website.Options = []string{"nomail"} - } - - r.Website.UID = user.UID - - dav := findMatchingDAVAccount(user, r) - if dav == nil { - return fmt.Errorf("no DAV resources matching website %s", r.String()) - } - return c.setCommonResourceAttrs(ctx, r, dav, user) -} - -// Apply default values to a DAV resource. -func (c *templateContext) davResourceTemplate(ctx context.Context, r *Resource, user *User) error { - if user == nil { - return errors.New("dav resource needs owner") - } - - // Force the account name to lowercase. - r.Name = strings.ToLower(r.Name) - - if r.DAV == nil { - r.DAV = new(WebDAV) - } - if r.DAV.Homedir == "" { - r.DAV.Homedir = filepath.Join(c.webroot, r.Name) - } - r.DAV.Homedir = filepath.Clean(r.DAV.Homedir) - r.DAV.UID = user.UID - - return c.setCommonResourceAttrs(ctx, r, nil, user) -} - -// Apply default values to a Database resource. -func (c *templateContext) databaseResourceTemplate(ctx context.Context, r *Resource, user *User) error { - if user == nil { - return errors.New("database resource needs owner") - } - - // Force the database name to lowercase. - r.Name = strings.ToLower(r.Name) - - if r.Database == nil { - r.Database = new(Database) - } - if r.Database.DBUser == "" { - r.Database.DBUser = r.Name - } - - return c.setCommonResourceAttrs(ctx, r, user.GetResourceByID(r.ParentID), user) -} - -// Apply default values to a MailingList resource. -func (c *templateContext) listResourceTemplate(ctx context.Context, r *Resource, user *User) error { - // Force the list address to lowercase. - r.Name = strings.ToLower(r.Name) - - if r.List == nil { - r.List = new(MailingList) - } - - // As a convenience, if a user is passed in the context, we add it to - // the list admins. - if user != nil && len(r.List.Admins) == 0 { - r.List.Admins = []string{user.Name} - } - - return c.setCommonResourceAttrs(ctx, r, nil, nil) -} - -// Apply default values to a Newsletter resource. -func (c *templateContext) newsletterResourceTemplate(ctx context.Context, r *Resource, user *User) error { - // Force the list address to lowercase. - r.Name = strings.ToLower(r.Name) - - if r.Newsletter == nil { - r.Newsletter = new(Newsletter) - } - - // As a convenience, if a user is passed in the context, we add it to - // the list admins. - if user != nil && len(r.Newsletter.Admins) == 0 { - r.Newsletter.Admins = []string{user.Name} - } - - return c.setCommonResourceAttrs(ctx, r, nil, nil) -} - -// Apply default values to a resource. -func (c *templateContext) applyTemplate(ctx context.Context, r *Resource, user *User) error { - switch r.Type { - case ResourceTypeEmail: - return c.emailResourceTemplate(ctx, r, user) - case ResourceTypeWebsite, ResourceTypeDomain: - return c.websiteResourceTemplate(ctx, r, user) - case ResourceTypeDAV: - return c.davResourceTemplate(ctx, r, user) - case ResourceTypeDatabase: - return c.databaseResourceTemplate(ctx, r, user) - case ResourceTypeMailingList: - return c.listResourceTemplate(ctx, r, user) - case ResourceTypeNewsletter: - return c.newsletterResourceTemplate(ctx, r, user) - } - return nil -} - func isSubdir(root, dir string) bool { return strings.HasPrefix(dir, root+"/") } -- GitLab