validators.go 25.9 KB
Newer Older
ale's avatar
ale committed
1 2 3 4 5 6 7
package accountserver

import (
	"bufio"
	"context"
	"errors"
	"fmt"
ale's avatar
ale committed
8
	"math/rand"
ale's avatar
ale committed
9
	"os"
ale's avatar
ale committed
10
	"path/filepath"
ale's avatar
ale committed
11 12 13 14 15 16 17 18
	"regexp"
	"strings"

	"golang.org/x/net/publicsuffix"
)

// A domainBackend manages the list of domains users are allowed to request services on.
type domainBackend interface {
19 20
	GetAllowedDomains(context.Context, string) []string
	IsAllowedDomain(context.Context, string, string) bool
ale's avatar
ale committed
21 22
}

ale's avatar
ale committed
23 24 25 26 27 28 29
// 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
}

30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
// ValidationConfig specifies a large number of validation-related
// configurable parameters.
type ValidationConfig struct {
	ForbiddenUsernames     []string            `yaml:"forbidden_usernames"`
	ForbiddenUsernamesFile string              `yaml:"forbidden_usernames_file"`
	ForbiddenPasswords     []string            `yaml:"forbidden_passwords"`
	ForbiddenPasswordsFile string              `yaml:"forbidden_passwords_file"`
	AvailableDomains       map[string][]string `yaml:"available_domains"`
	WebsiteRootDir         string              `yaml:"website_root_dir"`
	MinPasswordLen         int                 `yaml:"min_password_len"`
	MaxPasswordLen         int                 `yaml:"max_password_len"`
	MinUsernameLen         int                 `yaml:"min_username_len"`
	MaxUsernameLen         int                 `yaml:"max_username_len"`
	MinUID                 int                 `yaml:"min_backend_uid"`
	MaxUID                 int                 `yaml:"max_backend_uid"`

	forbiddenUsernames stringSet
	forbiddenPasswords stringSet
}

const (
	defaultMinPasswordLen = 8
	defaultMaxPasswordLen = 128
	defaultMinUsernameLen = 3
	defaultMaxUsernameLen = 64
	defaultMinUID         = 1000
	defaultMaxUID         = 0
)

func (c *ValidationConfig) setDefaults() {
	if c.MinPasswordLen == 0 {
		c.MinPasswordLen = defaultMinPasswordLen
	}
	if c.MaxPasswordLen == 0 {
		c.MaxPasswordLen = defaultMaxPasswordLen
	}
	if c.MinUsernameLen == 0 {
		c.MinUsernameLen = defaultMinUsernameLen
	}
	if c.MaxUsernameLen == 0 {
		c.MaxUsernameLen = defaultMaxUsernameLen
	}
	if c.MinUID == 0 {
		c.MinUID = defaultMinUID
	}
	if c.MaxUID == 0 {
		c.MaxUID = defaultMaxUID
	}
}

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)
	return
}

91
// The validationContext contains all configuration and backends that
ale's avatar
ale committed
92 93 94 95
// 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'.
96
type validationContext struct {
97 98 99 100
	config  *ValidationConfig
	domains domainBackend
	shards  shardBackend
	backend Backend
ale's avatar
ale committed
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116
}

// 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}
}

ale's avatar
ale committed
117 118 119 120 121 122 123
func newStringSetFromFileOrList(list []string, path string) (stringSet, error) {
	if path != "" {
		return loadStringSetFromFile(path)
	}
	return newStringSetFromList(list), nil
}

ale's avatar
ale committed
124 125 126 127 128 129 130 131 132 133 134 135 136 137
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
}

138
func (d *staticDomainBackend) GetAllowedDomains(_ context.Context, kind string) []string {
ale's avatar
ale committed
139 140 141
	return d.sets[kind].List()
}

142
func (d *staticDomainBackend) IsAllowedDomain(_ context.Context, kind, domain string) bool {
ale's avatar
ale committed
143 144 145
	return d.sets[kind].Contains(domain)
}

ale's avatar
ale committed
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162
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)
}

ale's avatar
ale committed
163
func loadStringSetFromFile(path string) (stringSet, error) {
ale's avatar
ale committed
164
	f, err := os.Open(path) // #nosec
ale's avatar
ale committed
165 166 167
	if err != nil {
		return stringSet{}, err
	}
ale's avatar
ale committed
168
	defer f.Close() // nolint: errcheck
ale's avatar
ale committed
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191

	var list []string
	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		line := scanner.Text()
		if line == "" {
			continue
		}
		list = append(list, line)
	}
	if err := scanner.Err(); err != nil {
		return stringSet{}, err
	}
	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 {
		if set.Contains(value) {
192
			return errors.New("invalid value (blacklisted)")
ale's avatar
ale committed
193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256
		}
		return nil
	}
}

func minLength(minLen int) ValidatorFunc {
	return func(_ context.Context, value string) error {
		if len(value) < minLen {
			return fmt.Errorf("value must be at least %d characters", minLen)
		}
		return nil
	}
}

func maxLength(maxLen int) ValidatorFunc {
	return func(_ context.Context, value string) error {
		if len(value) > maxLen {
			return fmt.Errorf("value must be at most %d characters", maxLen)
		}
		return nil
	}
}

func matchRegexp(rx *regexp.Regexp, errmsg string) ValidatorFunc {
	return func(_ context.Context, value string) error {
		if !rx.MatchString(value) {
			return errors.New(errmsg)
		}
		return nil
	}
}

var usernameRx = regexp.MustCompile(`^([a-z0-9]+[.-]?)+[a-z0-9]$`)

func matchUsernameRx() ValidatorFunc {
	return matchRegexp(usernameRx, "value must be an alphanumeric sequence, including . and - characters but not starting or ending with those")
}

func matchSitenameRx() ValidatorFunc {
	return matchUsernameRx()
}

func validateUsernameAndDomain(validateUsername, validateDomain ValidatorFunc) ValidatorFunc {
	return func(ctx context.Context, 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 {
			return err
		}
		return validateDomain(ctx, parts[1])
	}
}

// This is not a prescriptive domain name validation regex, it's just used to
// give useful feedback to the user if the domain name looks visibly wrong.
var domainRx = regexp.MustCompile(`^(?:[a-zA-Z0-9_\-]{1,63}\.)+(?:[a-zA-Z]{2,})$`)

func isRegistered(domain string) bool {
	// TODO: ha ha, there isn't really a good way to check.
	return true
}

ale's avatar
ale committed
257
func validDomainName(_ context.Context, value string) error {
ale's avatar
ale committed
258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282
	if !domainRx.MatchString(value) {
		return errors.New("invalid domain name")
	}

	// Extract the top-level domain and check that it is registered
	// (which works also if we're hosting the top-level domain
	// ourselves already).
	domain, err := publicsuffix.EffectiveTLDPlusOne(value)
	if err != nil {
		return errors.New("invalid TLD")
	}
	if !isRegistered(domain) {
		return fmt.Errorf("the domain %s does not seem to be registered", domain)
	}
	return nil
}

// Split a (correctly formed) email address into username/domain.
func splitEmailAddr(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.
ale's avatar
ale committed
283 284 285 286
func relatedEmails(ctx context.Context, be domainBackend, addr string) []FindResourceRequest {
	rel := []FindResourceRequest{
		{Type: ResourceTypeEmail, Name: addr},
	}
ale's avatar
ale committed
287 288 289 290
	user, _ := splitEmailAddr(addr)
	// Mailing lists 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.
291
	for _, d := range be.GetAllowedDomains(ctx, ResourceTypeMailingList) {
ale's avatar
ale committed
292 293 294 295
		rel = append(rel, FindResourceRequest{
			Type: ResourceTypeMailingList,
			Name: fmt.Sprintf("%s@%s", user, d),
		})
ale's avatar
ale committed
296
	}
ale's avatar
ale committed
297
	return rel
ale's avatar
ale committed
298 299
}

300 301 302 303 304 305 306 307
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,
		},
	}
ale's avatar
ale committed
308 309
}

310 311 312
func relatedDomains(ctx context.Context, be domainBackend, value string) []FindResourceRequest {
	return []FindResourceRequest{
		{
313 314
			Type: ResourceTypeDomain,
			Name: value,
315
		},
ale's avatar
ale committed
316 317 318
	}
}

319
func (v *validationContext) isAllowedDomain(rtype string) ValidatorFunc {
ale's avatar
ale committed
320
	return func(ctx context.Context, value string) error {
321
		if !v.domains.IsAllowedDomain(ctx, rtype, value) {
ale's avatar
ale committed
322 323 324 325 326 327
			return errors.New("unavailable domain")
		}
		return nil
	}
}

328
func (v *validationContext) isAvailableEmailAddr() ValidatorFunc {
ale's avatar
ale committed
329
	return func(ctx context.Context, value string) error {
330 331 332 333 334 335 336 337
		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
		}
ale's avatar
ale committed
338 339
		// Errors will cause to consider the address unavailable.
		if ok, _ := tx.HasAnyResource(ctx, rel); ok { // nolint
340
			return errors.New("address unavailable")
ale's avatar
ale committed
341 342 343 344 345
		}
		return nil
	}
}

346
func (v *validationContext) isAvailableDomain() ValidatorFunc {
ale's avatar
ale committed
347
	return func(ctx context.Context, value string) error {
348
		rel := relatedDomains(ctx, v.domains, value)
ale's avatar
ale committed
349 350 351

		// Run the presence check in a new transaction. Unavailability
		// of the server results in a validation error (fail close).
352
		tx, err := v.backend.NewTransaction()
ale's avatar
ale committed
353 354 355
		if err != nil {
			return err
		}
ale's avatar
ale committed
356 357
		// Errors will cause to consider the resource unavailable.
		if ok, _ := tx.HasAnyResource(ctx, rel); ok { // nolint
ale's avatar
ale committed
358 359 360 361 362 363
			return errors.New("address unavailable")
		}
		return nil
	}
}

364
func (v *validationContext) isAvailableWebsite() ValidatorFunc {
ale's avatar
ale committed
365
	return func(ctx context.Context, value string) error {
366
		rel := relatedWebsites(ctx, v.domains, value)
ale's avatar
ale committed
367 368 369

		// Run the presence check in a new transaction. Unavailability
		// of the server results in a validation error (fail close).
370
		tx, err := v.backend.NewTransaction()
ale's avatar
ale committed
371 372 373
		if err != nil {
			return err
		}
ale's avatar
ale committed
374 375
		// Errors will cause to consider the resource unavailable.
		if ok, _ := tx.HasAnyResource(ctx, rel); ok { // nolint
ale's avatar
ale committed
376 377 378 379 380 381
			return errors.New("address unavailable")
		}
		return nil
	}
}

382
func (v *validationContext) validHostedEmail() ValidatorFunc {
ale's avatar
ale committed
383 384
	return allOf(
		validateUsernameAndDomain(
385 386 387 388 389 390
			allOf(
				matchUsernameRx(),
				minLength(v.config.MinUsernameLen),
				maxLength(v.config.MaxUsernameLen),
				notInSet(v.config.forbiddenUsernames),
			),
391
			allOf(v.isAllowedDomain(ResourceTypeEmail)),
ale's avatar
ale committed
392
		),
393
		v.isAvailableEmailAddr(),
ale's avatar
ale committed
394 395 396
	)
}

397
func (v *validationContext) validHostedMailingList() ValidatorFunc {
ale's avatar
ale committed
398 399
	return allOf(
		validateUsernameAndDomain(
400 401 402 403 404 405
			allOf(
				matchUsernameRx(),
				minLength(v.config.MinUsernameLen),
				maxLength(v.config.MaxUsernameLen),
				notInSet(v.config.forbiddenUsernames),
			),
406
			allOf(v.isAllowedDomain(ResourceTypeMailingList)),
ale's avatar
ale committed
407
		),
408
		v.isAvailableEmailAddr(),
ale's avatar
ale committed
409 410 411
	)
}

412
func (v *validationContext) validPassword() ValidatorFunc {
ale's avatar
ale committed
413
	return allOf(
414 415 416
		minLength(v.config.MinPasswordLen),
		maxLength(v.config.MaxPasswordLen),
		notInSet(v.config.forbiddenPasswords),
417 418 419
	)
}

ale's avatar
ale committed
420 421 422 423 424 425 426 427 428 429
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
	}
}
ale's avatar
ale committed
430 431 432

// ResourceValidatorFunc is a composite type validator that checks
// various fields in a Resource, depending on its type.
ale's avatar
ale committed
433 434 435 436 437
type ResourceValidatorFunc func(context.Context, *Resource, *User) error

func (v *validationContext) validateResource(_ context.Context, r *Resource, user *User) error {
	// Validate the status enum.
	switch r.Status {
ale's avatar
ale committed
438
	case ResourceStatusActive, ResourceStatusInactive, ResourceStatusReadonly, ResourceStatusArchived:
ale's avatar
ale committed
439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460
	default:
		return errors.New("unknown resource status")
	}

	// If the resource has a ParentID, it must reference another
	// resource owned by the user.
	if !r.ParentID.Empty() {
		if user == nil {
			return errors.New("resource can't have parent without user context")
		}
		if p := user.GetResourceByID(r.ParentID); p == nil {
			return errors.New("parent references unknown resource")
		}
	}

	return nil
}

func (v *validationContext) validateShardedResource(ctx context.Context, r *Resource, user *User) error {
	if err := v.validateResource(ctx, r, user); err != nil {
		return err
	}
461 462 463
	if r.Shard == "" {
		return errors.New("empty shard")
	}
ale's avatar
ale committed
464
	if !v.shards.IsAllowedShard(ctx, r.Type, r.Shard) {
ale's avatar
ale committed
465 466 467
		return fmt.Errorf(
			"invalid shard %s for resource type %s (allowed: %v)",
			r.Shard,
ale's avatar
ale committed
468 469
			r.Type,
			v.shards.GetAllowedShards(ctx, r.Type),
ale's avatar
ale committed
470 471 472 473 474 475 476
		)
	}
	if r.OriginalShard == "" {
		return errors.New("empty original_shard")
	}
	return nil
}
ale's avatar
ale committed
477

478 479
func (v *validationContext) validEmailResource() ResourceValidatorFunc {
	emailValidator := v.validHostedEmail()
ale's avatar
ale committed
480

ale's avatar
ale committed
481 482 483 484 485 486 487 488 489 490 491 492 493
	return func(ctx context.Context, r *Resource, user *User) error {
		if err := v.validateShardedResource(ctx, r, user); err != nil {
			return err
		}

		// Email resources aren't nested.
		if !r.ParentID.Empty() {
			return errors.New("resource should not have parent")
		}

		if r.Email == nil {
			return errors.New("resource has no email metadata")
		}
ale's avatar
ale committed
494
		if err := emailValidator(ctx, r.Name); err != nil {
ale's avatar
ale committed
495 496 497 498 499 500
			return err
		}
		if r.Email.Maildir == "" {
			return errors.New("empty maildir")
		}
		return nil
ale's avatar
ale committed
501 502 503
	}
}

504 505
func (v *validationContext) validListResource() ResourceValidatorFunc {
	listValidator := v.validHostedMailingList()
ale's avatar
ale committed
506

ale's avatar
ale committed
507 508 509 510 511 512 513
	return func(ctx context.Context, r *Resource, user *User) error {
		if err := v.validateShardedResource(ctx, r, user); err != nil {
			return err
		}
		if r.List == nil {
			return errors.New("resource has no list metadata")
		}
ale's avatar
ale committed
514
		if err := listValidator(ctx, r.Name); err != nil {
ale's avatar
ale committed
515 516 517 518 519 520 521 522 523
			return err
		}
		if len(r.List.Admins) < 1 {
			return errors.New("can't create a list without admins")
		}
		return nil
	}
}

ale's avatar
ale committed
524
func findMatchingDAVAccount(user *User, r *Resource) *Resource {
ale's avatar
ale committed
525
	for _, dav := range user.GetResourcesByType(ResourceTypeDAV) {
ale's avatar
ale committed
526
		if isSubdir(dav.DAV.Homedir, r.Website.DocumentRoot) {
527
			return dav
ale's avatar
ale committed
528 529
		}
	}
ale's avatar
ale committed
530 531 532 533 534
	return nil
}

func hasMatchingDAVAccount(user *User, r *Resource) bool {
	return findMatchingDAVAccount(user, r) != nil
ale's avatar
ale committed
535 536
}

ale's avatar
ale committed
537 538 539 540
func (v *validationContext) validateUID(uid int, user *User) error {
	if uid == 0 {
		return errors.New("uid is not set")
	}
541 542
	if uid < v.config.MinUID || (v.config.MaxUID > 0 && uid > v.config.MaxUID) {
		return fmt.Errorf("uid %d outside of allowed range (%d-%d)", uid, v.config.MinUID, v.config.MaxUID)
ale's avatar
ale committed
543 544 545 546 547 548 549 550 551 552 553 554 555 556
	}
	if user != nil && uid != user.UID {
		return errors.New("uid of resource differs from uid of user")
	}
	return nil
}

func (v *validationContext) validateWebsiteCommon(r *Resource, user *User) error {
	// 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")
	}
557
	if !isSubdir(v.config.WebsiteRootDir, r.Website.DocumentRoot) {
ale's avatar
ale committed
558 559 560 561 562 563 564 565 566 567
		return errors.New("document root outside of web root")
	}
	if !hasMatchingDAVAccount(user, r) {
		return errors.New("website has no matching DAV account")
	}

	// UID checks.
	return v.validateUID(r.Website.UID, user)
}

568 569 570 571 572 573
func (v *validationContext) validDomainResource() ResourceValidatorFunc {
	domainValidator := allOf(
		minLength(6),
		validDomainName,
		v.isAvailableDomain(),
	)
ale's avatar
ale committed
574

ale's avatar
ale committed
575 576 577 578 579 580 581 582 583 584 585 586 587
	return func(ctx context.Context, r *Resource, user *User) error {
		if err := v.validateShardedResource(ctx, r, user); err != nil {
			return err
		}

		// Web resources aren't 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")
		}
ale's avatar
ale committed
588
		if err := domainValidator(ctx, r.Name); err != nil {
ale's avatar
ale committed
589 590 591 592 593 594
			return err
		}
		if r.Website.ParentDomain != "" {
			return errors.New("non-empty parent_domain on domain resource")
		}

ale's avatar
ale committed
595
		return v.validateWebsiteCommon(r, user)
ale's avatar
ale committed
596 597 598
	}
}

599 600 601 602 603 604 605 606
func (v *validationContext) validWebsiteResource() ResourceValidatorFunc {
	nameValidator := allOf(
		minLength(6),
		matchSitenameRx(),
		v.isAvailableWebsite(),
	)
	parentValidator := v.isAllowedDomain(ResourceTypeWebsite)

ale's avatar
ale committed
607 608 609 610 611 612 613 614 615 616 617 618 619
	return func(ctx context.Context, r *Resource, user *User) error {
		if err := v.validateShardedResource(ctx, r, user); err != nil {
			return err
		}

		// Web resources aren't 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")
		}
ale's avatar
ale committed
620
		if err := nameValidator(ctx, r.Name); err != nil {
621 622
			return err
		}
ale's avatar
ale committed
623 624 625 626
		if err := parentValidator(ctx, r.Website.ParentDomain); err != nil {
			return err
		}

ale's avatar
ale committed
627
		return v.validateWebsiteCommon(r, user)
ale's avatar
ale committed
628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644
	}
}

func (v *validationContext) validDAVResource() ResourceValidatorFunc {
	return func(ctx context.Context, r *Resource, user *User) error {
		if err := v.validateShardedResource(ctx, r, user); err != nil {
			return err
		}

		// DAV resources aren't 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")
		}
645
		if !isSubdir(v.config.WebsiteRootDir, r.DAV.Homedir) {
ale's avatar
ale committed
646 647
			return errors.New("homedir outside of web root")
		}
ale's avatar
ale committed
648 649

		return v.validateUID(r.DAV.UID, user)
ale's avatar
ale committed
650 651 652 653 654 655 656 657 658 659 660 661 662
	}
}

func (v *validationContext) validDatabaseResource() ResourceValidatorFunc {
	return func(ctx context.Context, r *Resource, user *User) error {
		if err := v.validateShardedResource(ctx, r, user); err != nil {
			return err
		}

		// Database resources must be nested below a website.
		if r.ParentID.Empty() {
			return errors.New("database resources should be nested")
		}
ale's avatar
ale committed
663 664 665 666 667 668 669

		// TODO: move to global validation
		// switch r.ParentID.Type() {
		// case ResourceTypeWebsite, ResourceTypeDomain:
		// default:
		// 	return errors.New("database parent is not a website resource")
		// }
ale's avatar
ale committed
670 671 672 673 674

		if r.Database == nil {
			return errors.New("resource has no database metadata")
		}
		return nil
675 676 677 678
	}
}

// Validator for arbitrary resource types.
ale's avatar
ale committed
679
type resourceValidator struct {
680
	rvs map[string]ResourceValidatorFunc
ale's avatar
ale committed
681 682
}

683
func newResourceValidator(v *validationContext) *resourceValidator {
ale's avatar
ale committed
684
	return &resourceValidator{
685 686 687 688 689
		rvs: map[string]ResourceValidatorFunc{
			ResourceTypeEmail:       v.validEmailResource(),
			ResourceTypeMailingList: v.validListResource(),
			ResourceTypeDomain:      v.validDomainResource(),
			ResourceTypeWebsite:     v.validWebsiteResource(),
ale's avatar
ale committed
690 691
			ResourceTypeDAV:         v.validDAVResource(),
			ResourceTypeDatabase:    v.validDatabaseResource(),
ale's avatar
ale committed
692 693 694 695
		},
	}
}

ale's avatar
ale committed
696
func (v *resourceValidator) validateResource(ctx context.Context, r *Resource, user *User) error {
ale's avatar
ale committed
697 698 699 700 701 702 703 704 705 706 707 708 709 710
	// Obvious basic sanity checks on the Resource parameters.
	if r.Name == "" {
		return errors.New("resource name unset")
	}
	if r.Type == "" {
		return errors.New("resource type unset")
	}

	rv, ok := v.rvs[r.Type]
	if !ok {
		return fmt.Errorf("unknown resource type '%s'", r.Type)
	}

	return rv(ctx, r, user)
711 712 713 714 715 716 717 718 719 720 721 722 723
}

// Common validators for specific field types.
type fieldValidators struct {
	password ValidatorFunc
	email    ValidatorFunc
}

func newFieldValidators(v *validationContext) *fieldValidators {
	return &fieldValidators{
		password: v.validPassword(),
		email:    v.validHostedEmail(),
	}
ale's avatar
ale committed
724
}
ale's avatar
ale committed
725

ale's avatar
ale committed
726
// UserValidatorFunc is a compound validator for User objects.
ale's avatar
ale committed
727 728
type UserValidatorFunc func(context.Context, *User) error

729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775
// Verify that user-level invariants are respected. This check can be applied
// to new or existing objects.
//
// nolint: gocyclo
func checkUserInvariants(user *User) error {
	// Count email resources. An account must have exactly 1 email
	// resource.
	var email *Resource
	var emailCount int
	for _, rsrc := range user.Resources {
		if rsrc.Type == ResourceTypeEmail {
			email = rsrc
			emailCount++
		}
	}
	if emailCount == 0 {
		return errors.New("account is missing email resource")
	}
	if emailCount > 1 {
		return errors.New("account can't have more than one email resource")
	}
	if email.Name != user.Name {
		return errors.New("email and username do not match")
	}

	// Check UID.
	if user.UID == 0 {
		return errors.New("UID unset")
	}
	// Check that all UIDs are the same.
	for _, rsrc := range user.Resources {
		switch rsrc.Type {
		case ResourceTypeWebsite, ResourceTypeDomain:
			if rsrc.Website.UID != user.UID {
				return fmt.Errorf("UID of resource %s does not match user (%d vs %d)", rsrc.String(), rsrc.Website.UID, user.UID)
			}
		case ResourceTypeDAV:
			if rsrc.DAV.UID != user.UID {
				return fmt.Errorf("UID of resource %s does not match user (%d vs %d)", rsrc.String(), rsrc.DAV.UID, user.UID)
			}
		}
	}

	return nil
}

// A custom validator for new User objects.
ale's avatar
ale committed
776 777 778
func (v *validationContext) validUser() UserValidatorFunc {
	nameValidator := v.validHostedEmail()
	return func(ctx context.Context, user *User) error {
779 780 781 782
		if err := nameValidator(ctx, user.Name); err != nil {
			return err
		}
		return checkUserInvariants(user)
ale's avatar
ale committed
783 784
	}
}
ale's avatar
ale committed
785 786 787 788 789 790

type templateContext struct {
	shards  shardBackend
	webroot string
}

791
func (c *templateContext) pickShard(ctx context.Context, r *Resource) (string, error) {
ale's avatar
ale committed
792
	avail := c.shards.GetAvailableShards(ctx, r.Type)
ale's avatar
ale committed
793
	if len(avail) == 0 {
794
		return "", fmt.Errorf("no available shards for resource type %s", r.Type)
ale's avatar
ale committed
795
	}
796
	return avail[rand.Intn(len(avail))], nil
ale's avatar
ale committed
797 798
}

799
func (c *templateContext) setResourceShard(ctx context.Context, r *Resource, ref *Resource) error {
ale's avatar
ale committed
800 801 802 803
	if r.Shard == "" {
		if ref != nil {
			r.Shard = ref.Shard
		} else {
804 805 806 807 808
			s, err := c.pickShard(ctx, r)
			if err != nil {
				return err
			}
			r.Shard = s
ale's avatar
ale committed
809 810 811 812 813
		}
	}
	if r.OriginalShard == "" {
		r.OriginalShard = r.Shard
	}
814
	return nil
ale's avatar
ale committed
815 816 817 818 819 820 821 822
}

func (c *templateContext) setResourceStatus(r *Resource) {
	if r.Status == "" {
		r.Status = ResourceStatusActive
	}
}

823 824 825 826 827 828 829 830 831 832 833 834 835 836
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
		}
	}

	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 {
ale's avatar
ale committed
837 838 839
	if r.Email == nil {
		r.Email = new(Email)
	}
ale's avatar
ale committed
840
	addrParts := strings.Split(r.Name, "@")
841 842 843
	if len(addrParts) != 2 {
		return errors.New("malformed name")
	}
ale's avatar
ale committed
844 845
	r.Email.Maildir = fmt.Sprintf("%s/%s", addrParts[1], addrParts[0])
	r.Email.QuotaLimit = 4096
846
	return c.setCommonResourceAttrs(ctx, r, nil, nil)
ale's avatar
ale committed
847 848
}

849 850 851 852
// 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")
ale's avatar
ale committed
853
	}
ale's avatar
ale committed
854

855 856
	if r.Website == nil {
		r.Website = new(Website)
ale's avatar
ale committed
857 858
	}

859 860
	// If the client did not specify a DocumentRoot, find a DAV resource
	// and associate the website with it.
ale's avatar
ale committed
861
	if r.Website.DocumentRoot == "" {
862 863 864 865 866 867 868 869 870
		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
ale's avatar
ale committed
871 872
			}
		}
873
		r.Website.DocumentRoot = filepath.Join(dav.DAV.Homedir, "html-"+r.Name)
ale's avatar
ale committed
874 875 876 877 878 879 880
	}
	r.Website.DocumentRoot = filepath.Clean(r.Website.DocumentRoot)

	if len(r.Website.Options) == 0 {
		r.Website.Options = []string{"nomail"}
	}

ale's avatar
ale committed
881 882
	r.Website.UID = user.UID

ale's avatar
ale committed
883
	dav := findMatchingDAVAccount(user, r)
884 885 886 887
	if dav == nil {
		return fmt.Errorf("no DAV resources matching website %s", r.String())
	}
	return c.setCommonResourceAttrs(ctx, r, dav, user)
ale's avatar
ale committed
888 889
}

890 891 892 893 894
// 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")
	}
ale's avatar
ale committed
895 896 897 898
	if r.DAV == nil {
		r.DAV = new(WebDAV)
	}
	if r.DAV.Homedir == "" {
ale's avatar
ale committed
899
		r.DAV.Homedir = filepath.Join(c.webroot, r.Name)
ale's avatar
ale committed
900 901
	}
	r.DAV.Homedir = filepath.Clean(r.DAV.Homedir)
ale's avatar
ale committed
902
	r.DAV.UID = user.UID
ale's avatar
ale committed
903

904
	return c.setCommonResourceAttrs(ctx, r, nil, user)
ale's avatar
ale committed
905 906
}

907 908 909 910 911
// 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")
	}
ale's avatar
ale committed
912 913 914 915
	if r.Database == nil {
		r.Database = new(Database)
	}
	if r.Database.DBUser == "" {
ale's avatar
ale committed
916
		r.Database.DBUser = r.Name
ale's avatar
ale committed
917 918
	}

919 920 921 922 923 924 925 926
	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 {
	if r.List == nil {
		r.List = new(MailingList)
	}
ale's avatar
ale committed
927

928 929 930 931 932 933 934
	// 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)
ale's avatar
ale committed
935 936
}

937 938
// Apply default values to a resource.
func (c *templateContext) applyTemplate(ctx context.Context, r *Resource, user *User) error {
939
	switch r.Type {
ale's avatar
ale committed
940
	case ResourceTypeEmail:
941
		return c.emailResourceTemplate(ctx, r, user)
ale's avatar
ale committed
942
	case ResourceTypeWebsite, ResourceTypeDomain:
943
		return c.websiteResourceTemplate(ctx, r, user)
ale's avatar
ale committed
944
	case ResourceTypeDAV:
945
		return c.davResourceTemplate(ctx, r, user)
ale's avatar
ale committed
946
	case ResourceTypeDatabase:
947 948 949
		return c.databaseResourceTemplate(ctx, r, user)
	case ResourceTypeMailingList:
		return c.listResourceTemplate(ctx, r, user)
ale's avatar
ale committed
950
	}
951
	return nil
ale's avatar
ale committed
952 953 954 955 956
}

func isSubdir(root, dir string) bool {
	return strings.HasPrefix(dir, root+"/")
}