Commit 2fd20609 authored by ale's avatar ale

Implement resource templates

Let the server fill in default values for resource and user creation.
parent e8b91a57
......@@ -662,6 +662,13 @@ type CreateResourcesResponse struct {
Resources []*Resource `json:"resources"`
}
// ApplyTemplate fills in default values for the resources in the request.
func (req *CreateResourcesRequest) ApplyTemplate(ctx context.Context, s *AccountService, user *User) {
for _, r := range req.Resources {
s.resourceTemplates.applyTemplate(ctx, r, user)
}
}
// Validate the request.
func (req *CreateResourcesRequest) Validate(ctx context.Context, s *AccountService, user *User) error {
var owner string
......@@ -743,6 +750,13 @@ type CreateUserRequest struct {
User *User `json:"user"`
}
// ApplyTemplate fills in default values for the resources in the request.
func (req *CreateUserRequest) ApplyTemplate(ctx context.Context, s *AccountService, user *User) {
for _, r := range req.User.Resources {
s.resourceTemplates.applyTemplate(ctx, r, user)
}
}
// Validate the request.
func (req *CreateUserRequest) Validate(ctx context.Context, s *AccountService, user *User) error {
// Override server-generated values.
......
......@@ -13,6 +13,7 @@ type Config struct {
ForbiddenPasswords []string `yaml:"forbidden_passwords"`
ForbiddenPasswordsFile string `yaml:"forbidden_passwords_file"`
AvailableDomains map[string][]string `yaml:"available_domains"`
WebsiteRootDir string `yaml:"website_root_dir"`
Shards struct {
Available map[string][]string `yaml:"available"`
......@@ -65,12 +66,20 @@ func (c *Config) validationContext(be Backend) (*validationContext, error) {
forbiddenPasswords: fp,
minPasswordLength: 6,
maxPasswordLength: 128,
webroot: c.WebsiteRootDir,
domains: c.domainBackend(),
shards: c.shardBackend(),
backend: be,
}, nil
}
func (c *Config) templateContext() *templateContext {
return &templateContext{
shards: c.shardBackend(),
webroot: c.WebsiteRootDir,
}
}
func (c *Config) ssoValidator() (sso.Validator, error) {
pkey, err := ioutil.ReadFile(c.SSO.PublicKeyFile)
if err != nil {
......
......@@ -123,13 +123,16 @@ func startService(t testing.TB) (func(), *testClient) {
svcConfig.AvailableDomains = map[string][]string{
accountserver.ResourceTypeEmail: []string{"example.com"},
}
shards := []string{"host1", "host2", "host3"}
svcConfig.Shards.Available = map[string][]string{
accountserver.ResourceTypeEmail: []string{"host1", "host2", "host3"},
accountserver.ResourceTypeWebsite: []string{"host1", "host2", "host3"},
accountserver.ResourceTypeDomain: []string{"host1", "host2", "host3"},
accountserver.ResourceTypeDAV: []string{"host1", "host2", "host3"},
accountserver.ResourceTypeEmail: shards,
accountserver.ResourceTypeWebsite: shards,
accountserver.ResourceTypeDomain: shards,
accountserver.ResourceTypeDAV: shards,
accountserver.ResourceTypeDatabase: shards,
}
svcConfig.Shards.Allowed = svcConfig.Shards.Available
svcConfig.WebsiteRootDir = "/home/users/investici.org"
service, err := accountserver.NewAccountService(be, &svcConfig)
if err != nil {
......@@ -286,7 +289,7 @@ func TestIntegration_CreateResource(t *testing.T) {
false,
},
// Malformed website metadata (empty document root).
// Empty document root will be fixed by templating.
{
&accountserver.Resource{
ID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"),
......@@ -294,11 +297,9 @@ func TestIntegration_CreateResource(t *testing.T) {
Status: accountserver.ResourceStatusActive,
Shard: "host2",
OriginalShard: "host2",
Website: &accountserver.Website{
URL: "https://example3.com",
},
Website: &accountserver.Website{},
},
false,
true,
},
// Malformed resource metadata (name fails validation).
......@@ -321,13 +322,13 @@ func TestIntegration_CreateResource(t *testing.T) {
{
&accountserver.Resource{
ID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"),
Name: "example3.com",
Name: "example4.com",
Status: accountserver.ResourceStatusActive,
Shard: "zebra",
OriginalShard: "zebra",
Website: &accountserver.Website{
URL: "https://example3.com",
DocumentRoot: "/home/users/investici.org/uno/html-example3.com",
URL: "https://example4.com",
DocumentRoot: "/home/users/investici.org/uno/html-example4.com",
},
},
false,
......@@ -337,13 +338,13 @@ func TestIntegration_CreateResource(t *testing.T) {
{
&accountserver.Resource{
ID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"),
Name: "example3.com",
Name: "example5.com",
Status: accountserver.ResourceStatusActive,
Shard: "host2",
OriginalShard: "host2",
Website: &accountserver.Website{
URL: "https://example3.com",
DocumentRoot: "/foo/bar/example3.com",
URL: "https://example5.com",
DocumentRoot: "/home/users/investici.org/nonexisting",
},
},
false,
......@@ -363,33 +364,27 @@ func TestIntegration_CreateResource(t *testing.T) {
}
}
func TestIntegration_CreateMultipleResources(t *testing.T) {
func TestIntegration_CreateMultipleResources_WithTemplate(t *testing.T) {
stop, c := startService(t)
defer stop()
// The create request is very bare, most values will be filled
// in by the server using resource templates.
err := c.request("/api/resource/create", &accountserver.CreateResourcesRequest{
SSO: c.ssoTicket(testAdminUser),
Resources: []*accountserver.Resource{
&accountserver.Resource{
ID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"),
Name: "example3.com",
Status: accountserver.ResourceStatusActive,
Shard: "host2",
OriginalShard: "host2",
Website: &accountserver.Website{
URL: "https://example3.com",
DocumentRoot: "/foo/bar/example3.com",
},
ID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"),
Name: "example3.com",
},
&accountserver.Resource{
ID: accountserver.NewResourceID(accountserver.ResourceTypeDAV, "uno@investici.org", "example3dav"),
Name: "example3dav",
Status: accountserver.ResourceStatusActive,
Shard: "host2",
OriginalShard: "host2",
DAV: &accountserver.WebDAV{
Homedir: "/foo/bar",
},
ID: accountserver.NewResourceID(accountserver.ResourceTypeDAV, "uno@investici.org", "example3dav"),
Name: "example3dav",
},
&accountserver.Resource{
ID: accountserver.NewResourceID(accountserver.ResourceTypeDatabase, "uno@investici.org", "cn=example3.com", "example3"),
ParentID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"),
Name: "example3",
},
},
}, nil)
......
......@@ -74,6 +74,7 @@ type AccountService struct {
fieldValidators *fieldValidators
resourceValidator *resourceValidator
userValidator UserValidatorFunc
resourceTemplates *templateContext
}
// NewAccountService builds a new AccountService with the specified configuration.
......@@ -103,6 +104,8 @@ func newAccountServiceWithSSO(backend Backend, config *Config, ssoValidator sso.
s.resourceValidator = newResourceValidator(vc)
s.userValidator = vc.validUser()
s.resourceTemplates = config.templateContext()
return s, nil
}
......@@ -261,6 +264,10 @@ type hasNewContext interface {
NewContext(context.Context) context.Context
}
type hasApplyTemplate interface {
ApplyTemplate(context.Context, *AccountService, *User)
}
type hasValidate interface {
Validate(context.Context, *AccountService) error
}
......@@ -273,9 +280,24 @@ type hasCompoundValidate interface {
// (mostly in the Context, used for later logging). The user
// parameter, if present, is passed to the Validate request method.
func (s *AccountService) withRequest(ctx context.Context, req interface{}, user *User, f func(context.Context) error) error {
// If the request has a NewContext() method, call it to obtain
// a request-specific context (this step usually adds
// parameters for logging).
if rnc, ok := req.(hasNewContext); ok {
ctx = rnc.NewContext(ctx)
}
// Apply a template to the request, to fill in default values
// etc., if the request has an ApplyTemplate() method.
if rt, ok := req.(hasApplyTemplate); ok {
rt.ApplyTemplate(ctx, s, user)
}
// If the request has a Validate() method, validate the
// request. We support two different fingerprints for the
// Validate() method, one without, and the other with a *User
// argument ("compound Validate"), for resource-level
// validators.
if rv, ok := req.(hasValidate); ok {
if err := rv.Validate(ctx, s); err != nil {
return newRequestError(err)
......
......@@ -86,6 +86,8 @@ type AppSpecificPasswordInfo struct {
Comment string `json:"comment"`
}
// Well-known user encryption key types, corresponding to primary and
// secondary passwords.
const (
UserEncryptionKeyMainID = "main"
UserEncryptionKeyRecoveryID = "recovery"
......@@ -98,6 +100,7 @@ type UserEncryptionKey struct {
Key []byte `json:"key"`
}
// Resource types.
const (
ResourceTypeEmail = "email"
ResourceTypeMailingList = "list"
......@@ -107,6 +110,7 @@ const (
ResourceTypeDatabase = "db"
)
// Resource status values.
const (
ResourceStatusActive = "active"
ResourceStatusInactive = "inactive"
......@@ -300,7 +304,7 @@ type WebDAV struct {
// Website resource attributes.
type Website struct {
URL string `json:"url"`
URL string `json:"url,omitempty"`
ParentDomain string `json:"parent_domain,omitempty"`
AcceptMail bool `json:"accept_mail"`
Options []string `json:"options,omitempty"`
......
......@@ -5,7 +5,10 @@ import (
"context"
"errors"
"fmt"
"log"
"math/rand"
"os"
"path/filepath"
"regexp"
"strings"
......@@ -35,6 +38,7 @@ type validationContext struct {
forbiddenPasswords stringSet
minPasswordLength int
maxPasswordLength int
webroot string
domains domainBackend
shards shardBackend
backend Backend
......@@ -462,13 +466,17 @@ func (v *validationContext) validListResource() ResourceValidatorFunc {
}
}
func hasMatchingDAVAccount(user *User, r *Resource) bool {
func findMatchingDAVAccount(user *User, r *Resource) *Resource {
for _, dav := range user.GetResourcesByType(ResourceTypeDAV) {
if strings.HasPrefix(r.Website.DocumentRoot, dav.DAV.Homedir+"/") {
return true
if isSubdir(dav.DAV.Homedir, r.Website.DocumentRoot) {
return r
}
}
return false
return nil
}
func hasMatchingDAVAccount(user *User, r *Resource) bool {
return findMatchingDAVAccount(user, r) != nil
}
func (v *validationContext) validDomainResource() ResourceValidatorFunc {
......@@ -502,6 +510,9 @@ func (v *validationContext) validDomainResource() ResourceValidatorFunc {
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")
}
......@@ -543,6 +554,9 @@ func (v *validationContext) validWebsiteResource() ResourceValidatorFunc {
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")
}
......@@ -564,6 +578,9 @@ func (v *validationContext) validDAVResource() ResourceValidatorFunc {
if r.DAV == nil {
return errors.New("resource has no dav metadata")
}
if !isSubdir(v.webroot, r.DAV.Homedir) {
return errors.New("homedir outside of web root")
}
return nil
}
}
......@@ -636,3 +653,128 @@ func (v *validationContext) validUser() UserValidatorFunc {
return nameValidator(ctx, user.Name)
}
}
// A ResourceTemplateFunc fills up server-generated fields with
// defaults for newly created resources. Called before validation.
type ResourceTemplateFunc func(context.Context, *Resource, *User)
type templateContext struct {
shards shardBackend
webroot string
}
func (c *templateContext) pickShard(ctx context.Context, r *Resource) string {
avail := c.shards.GetAvailableShards(ctx, r.ID.Type())
if len(avail) == 0 {
return ""
}
return avail[rand.Intn(len(avail))]
}
func (c *templateContext) setResourceShard(ctx context.Context, r *Resource, ref *Resource) {
if r.Shard == "" {
if ref != nil {
// If we are evaluating templates out of
// order, the reference resource may not have
// a shard yet. Assign it now.
if ref.Shard == "" {
ref.Shard = c.pickShard(ctx, ref)
}
r.Shard = ref.Shard
} else {
r.Shard = c.pickShard(ctx, r)
}
}
if r.OriginalShard == "" {
r.OriginalShard = r.Shard
}
}
func (c *templateContext) setResourceStatus(r *Resource) {
if r.Status == "" {
r.Status = ResourceStatusActive
}
}
func (c *templateContext) emailResourceTemplate(ctx context.Context, r *Resource, user *User) {
if r.Email == nil {
r.Email = new(Email)
}
addrParts := strings.Split(r.ID.Name(), "@")
r.Email.Maildir = fmt.Sprintf("%s/%s", addrParts[1], addrParts[0])
r.Email.QuotaLimit = 4096
c.setResourceShard(ctx, r, nil)
c.setResourceStatus(r)
}
func (c *templateContext) websiteResourceTemplate(ctx context.Context, r *Resource, user *User) {
if r.Website == nil {
r.Website = new(Website)
}
if r.Website.DocumentRoot == "" {
if dav := user.GetSingleResourceByType(ResourceTypeDAV); dav != nil {
// The DAV resource may not have been templatized yet.
if dav.DAV == nil || dav.DAV.Homedir == "" {
c.davResourceTemplate(ctx, dav, user)
}
r.Website.DocumentRoot = filepath.Join(dav.DAV.Homedir, "html-"+r.ID.Name())
}
}
r.Website.DocumentRoot = filepath.Clean(r.Website.DocumentRoot)
if len(r.Website.Options) == 0 {
r.Website.Options = []string{"nomail"}
}
dav := findMatchingDAVAccount(user, r)
c.setResourceShard(ctx, r, dav)
c.setResourceStatus(r)
log.Printf("applyTemplate(%s) -> %+v", r.ID, r.Website)
}
func (c *templateContext) davResourceTemplate(ctx context.Context, r *Resource, user *User) {
if r.DAV == nil {
r.DAV = new(WebDAV)
}
if r.DAV.Homedir == "" {
r.DAV.Homedir = filepath.Join(c.webroot, r.ID.Name())
}
r.DAV.Homedir = filepath.Clean(r.DAV.Homedir)
c.setResourceShard(ctx, r, nil)
c.setResourceStatus(r)
log.Printf("applyTemplate(%s) -> %+v", r.ID, r.DAV)
}
func (c *templateContext) databaseResourceTemplate(ctx context.Context, r *Resource, user *User) {
if r.Database == nil {
r.Database = new(Database)
}
if r.Database.DBUser == "" {
r.Database.DBUser = r.ID.Name()
}
c.setResourceShard(ctx, r, user.GetResourceByID(r.ParentID))
c.setResourceStatus(r)
log.Printf("applyTemplate(%s) -> %+v", r.ID, r.Database)
}
func (c *templateContext) applyTemplate(ctx context.Context, r *Resource, user *User) {
switch r.ID.Type() {
case ResourceTypeEmail:
c.emailResourceTemplate(ctx, r, user)
case ResourceTypeWebsite, ResourceTypeDomain:
c.websiteResourceTemplate(ctx, r, user)
case ResourceTypeDAV:
c.davResourceTemplate(ctx, r, user)
case ResourceTypeDatabase:
c.databaseResourceTemplate(ctx, r, user)
}
}
func isSubdir(root, dir string) bool {
return strings.HasPrefix(dir, root+"/")
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment