Commit e2ae4a91 authored by ale's avatar ale

Allow creation of lists, and enforce user-level invariants

CreateResourcesRequest now lets the owner be optional, allowing for
creation of owner-less resources like mailing lists.

Both CreateResources and CreateUser now check that user-level
invariants (criteria such as "1 email per user") are respected.
parent eb8a0359
......@@ -2,7 +2,6 @@ package accountserver
import (
"context"
"errors"
"fmt"
"log"
......@@ -11,13 +10,29 @@ import (
// CreateResourcesRequest lets administrators create one or more resources.
type CreateResourcesRequest struct {
AdminUserRequestBase
AdminRequestBase
// Username the resources will belong to (optional).
Username string `json:"username"`
// Resources to create. All must either be global resources
// (no user ownership), or belong to the same user.
Resources []*Resource `json:"resources"`
}
// PopulateContext extracts information from the request and stores it
// into the RequestContext.
func (r *CreateResourcesRequest) PopulateContext(rctx *RequestContext) error {
if r.Username != "" {
user, err := getUserOrDie(rctx.Context, rctx.TX, r.Username)
if err != nil {
return err
}
rctx.User = user
}
return r.AdminRequestBase.PopulateContext(rctx)
}
// CreateResourcesResponse is the response type for CreateResourcesRequest.
type CreateResourcesResponse struct {
// Resources that were created.
......@@ -44,32 +59,54 @@ func mergeResources(a, b []*Resource) ([]*Resource, error) {
// Validate the request.
func (r *CreateResourcesRequest) Validate(rctx *RequestContext) error {
// To provide resource validators with a view of what the User should be
// with the new resources, we merge the ones from the database with the
// ones from the request. This is also a good time to check for
// uniqueness (even though it would fail at commit time anyway).
merged, err := mergeResources(rctx.User.Resources, r.Resources)
if err != nil {
return err
var tplUser *User
if rctx.User != nil {
// To provide resource validators with a view of what the User should be
// with the new resources, we merge the ones from the database with the
// ones from the request. This is also a good time to check for
// uniqueness (even though it would fail at commit time anyway).
merged, err := mergeResources(rctx.User.Resources, r.Resources)
if err != nil {
return err
}
userCopy := rctx.User.User
userCopy.Resources = merged
tplUser = &userCopy
}
tplUser := rctx.User.User
tplUser.Resources = merged
for _, rsrc := range r.Resources {
rctx.resourceTemplates.applyTemplate(rctx.Context, rsrc, &tplUser)
// Apply resource templates.
if err := rctx.resourceTemplates.applyTemplate(rctx.Context, rsrc, tplUser); err != nil {
return err
}
// Validate the resource.
if err := rctx.resourceValidator.validateResource(rctx.Context, rsrc, &tplUser); err != nil {
if err := rctx.resourceValidator.validateResource(rctx.Context, rsrc, tplUser); err != nil {
log.Printf("validation error while creating resource %s: %v", rsrc.String(), err)
return err
}
}
if tplUser != nil {
// If the resource has an owner, validate it (checks that the new
// resources do not violate user invariants).
if err := checkUserInvariants(tplUser); err != nil {
log.Printf("validation error while creating resources: %v", err)
return err
}
}
return nil
}
// Serve the request.
func (r *CreateResourcesRequest) Serve(rctx *RequestContext) (interface{}, error) {
rsrcs, err := rctx.TX.CreateResources(rctx.Context, &rctx.User.User, r.Resources)
var user *User
if rctx.User != nil {
user = &rctx.User.User
}
rsrcs, err := rctx.TX.CreateResources(rctx.Context, user, r.Resources)
if err != nil {
return nil, err
}
......@@ -114,7 +151,13 @@ func (r *CreateUserRequest) applyTemplate(rctx *RequestContext) error {
// Apply templates to all resources in the request.
for _, rsrc := range r.User.Resources {
rctx.resourceTemplates.applyTemplate(rctx.Context, rsrc, r.User)
if err := rctx.resourceTemplates.applyTemplate(rctx.Context, rsrc, r.User); err != nil {
return err
}
// Set the user shard to match the email resource shard.
if rsrc.Type == ResourceTypeEmail {
r.User.Shard = rsrc.Shard
}
}
return nil
......@@ -128,36 +171,17 @@ func (r *CreateUserRequest) Validate(rctx *RequestContext) error {
// Validate the user *and* all resources. The request must contain at
// least one email resource with the same name as the user.
if err := rctx.userValidator(rctx.Context, r.User); err != nil {
log.Printf("validation error while creating user %+v: %v", r.User, err)
return err
}
var emailCount int
for _, rsrc := range r.User.Resources {
if err := rctx.resourceValidator.validateResource(rctx.Context, rsrc, r.User); err != nil {
log.Printf("validation error while creating resource %+v: %v", rsrc, err)
return err
}
if rsrc.Type == ResourceTypeEmail {
emailCount++
}
}
if emailCount == 0 {
return errors.New("missing email resource")
}
if emailCount > 1 {
return errors.New("too many email resources")
}
email := r.User.GetSingleResourceByType(ResourceTypeEmail)
if email.Name != r.User.Name {
return errors.New("user and email resource names do not match")
if err := rctx.userValidator(rctx.Context, r.User); err != nil {
log.Printf("validation error while creating user %+v: %v", r.User, err)
return err
}
// Now that validation is done, finalize the object by setting some derived parameters.
// Set the user shard to the email shard.
r.User.Shard = email.Shard
return nil
}
......
......@@ -11,6 +11,8 @@ import (
sso "git.autistici.org/id/go-sso"
)
const testUser = "testuser@example.com"
type fakeBackend struct {
users map[string]*User
resources map[string]*Resource
......@@ -211,14 +213,14 @@ func createFakeBackend() *fakeBackend {
encryptionKeys: make(map[string][]*UserEncryptionKey),
}
fb.addUser(&User{
Name: "testuser",
Name: testUser,
Status: UserStatusActive,
Shard: "1",
UID: 4242,
Resources: []*Resource{
{
ID: makeResourceID("testuser", ResourceTypeEmail, "testuser@example.com"),
Name: "testuser@example.com",
ID: makeResourceID(testUser, ResourceTypeEmail, testUser),
Name: testUser,
Type: ResourceTypeEmail,
Status: ResourceStatusActive,
Shard: "1",
......@@ -227,11 +229,12 @@ func createFakeBackend() *fakeBackend {
},
},
{
ID: makeResourceID("testuser", ResourceTypeDAV, "dav1"),
ID: makeResourceID(testUser, ResourceTypeDAV, "dav1"),
Name: "dav1",
Type: ResourceTypeDAV,
Status: ResourceStatusActive,
DAV: &WebDAV{
UID: 4242,
Homedir: "/home/dav1",
},
},
......@@ -271,9 +274,9 @@ func getUser(t testing.TB, svc *AccountService, username string) *User {
req := &GetUserRequest{
UserRequestBase: UserRequestBase{
RequestBase: RequestBase{
SSO: "testuser",
SSO: testUser,
},
Username: "testuser",
Username: testUser,
},
}
resp, err := svc.Handle(context.TODO(), req)
......@@ -286,8 +289,8 @@ func getUser(t testing.TB, svc *AccountService, username string) *User {
func TestService_GetUser(t *testing.T) {
svc := testService("")
user := getUser(t, svc, "testuser")
if user.Name != "testuser" {
user := getUser(t, svc, testUser)
if user.Name != testUser {
t.Fatalf("bad response: %+v", user)
}
}
......@@ -386,7 +389,7 @@ func TestService_Auth(t *testing.T) {
sso string
expectedOk bool
}{
{"testuser", true},
{testUser, true},
{"otheruser", false},
{"adminuser", true},
} {
......@@ -395,7 +398,7 @@ func TestService_Auth(t *testing.T) {
RequestBase: RequestBase{
SSO: td.sso,
},
Username: "testuser",
Username: testUser,
},
}
_, err := svc.Handle(context.TODO(), req)
......@@ -435,9 +438,9 @@ func TestService_ChangePassword(t *testing.T) {
PrivilegedRequestBase: PrivilegedRequestBase{
UserRequestBase: UserRequestBase{
RequestBase: RequestBase{
SSO: "testuser",
SSO: testUser,
},
Username: "testuser",
Username: testUser,
},
CurPassword: td.password,
},
......@@ -451,10 +454,10 @@ func TestService_ChangePassword(t *testing.T) {
}
}
if _, ok := fb.passwords["testuser"]; !ok {
if _, ok := fb.passwords[testUser]; !ok {
t.Error("password was not set on the backend")
}
// if len(fb.encryptionKeys["testuser"]) != 1 {
// if len(fb.encryptionKeys[testUser]) != 1 {
// t.Errorf("no encryption keys were set")
// }
}
......@@ -469,7 +472,7 @@ func TestService_ChangePassword(t *testing.T) {
// tx, _ := fb.NewTransaction()
// ctx := context.Background()
// user, _ := getUserOrDie(ctx, tx, "testuser")
// user, _ := getUserOrDie(ctx, tx, testUser)
// // Set the keys to something.
// keys, _, err := svc.initializeEncryptionKeys(ctx, tx, user, "password")
......@@ -479,7 +482,7 @@ func TestService_ChangePassword(t *testing.T) {
// if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
// t.Fatal("SetUserEncryptionKeys", err)
// }
// if n := len(fb.encryptionKeys["testuser"]); n != 1 {
// if n := len(fb.encryptionKeys[testUser]); n != 1 {
// t.Fatalf("found %d encryption keys, expected 1", n)
// }
......@@ -497,7 +500,7 @@ func TestService_AddEmailAlias(t *testing.T) {
svc := testService("")
// Find the resource ID.
user := getUser(t, svc, "testuser")
user := getUser(t, svc, testUser)
emailID := user.GetSingleResourceByType(ResourceTypeEmail).ID
testdata := []struct {
......@@ -513,7 +516,7 @@ func TestService_AddEmailAlias(t *testing.T) {
req := &AddEmailAliasRequest{
ResourceRequestBase: ResourceRequestBase{
RequestBase: RequestBase{
SSO: "testuser",
SSO: testUser,
},
ResourceID: emailID,
},
......@@ -532,14 +535,12 @@ func TestService_CreateResource(t *testing.T) {
svc := testService("admin")
req := &CreateResourcesRequest{
AdminUserRequestBase: AdminUserRequestBase{
UserRequestBase: UserRequestBase{
RequestBase: RequestBase{
SSO: "admin",
},
Username: "testuser",
AdminRequestBase: AdminRequestBase{
RequestBase: RequestBase{
SSO: "admin",
},
},
Username: testUser,
Resources: []*Resource{
&Resource{
Type: ResourceTypeDAV,
......@@ -582,14 +583,12 @@ func TestService_CreateResource_List(t *testing.T) {
// A list is an example of a user-less (global) resource.
req := &CreateResourcesRequest{
AdminUserRequestBase: AdminUserRequestBase{
UserRequestBase: UserRequestBase{
RequestBase: RequestBase{
SSO: "admin",
},
Username: "testuser",
AdminRequestBase: AdminRequestBase{
RequestBase: RequestBase{
SSO: "admin",
},
},
Username: testUser,
Resources: []*Resource{
&Resource{
Type: ResourceTypeMailingList,
......@@ -598,7 +597,7 @@ func TestService_CreateResource_List(t *testing.T) {
Shard: "host2",
OriginalShard: "host2",
List: &MailingList{
Admins: []string{"testuser"},
Admins: []string{testUser},
},
},
},
......@@ -671,7 +670,7 @@ func TestService_CreateUser_FailIfNotAdmin(t *testing.T) {
req := &CreateUserRequest{
AdminRequestBase: AdminRequestBase{
RequestBase: RequestBase{
SSO: "testuser",
SSO: testUser,
},
},
User: &User{
......@@ -701,12 +700,12 @@ func TestService_CreateUser_FailIfNotAdmin(t *testing.T) {
func TestService_ResetResourcePassword(t *testing.T) {
svc := testService("")
user := getUser(t, svc, "testuser")
user := getUser(t, svc, testUser)
id := user.GetSingleResourceByType(ResourceTypeDAV).ID
req := &ResetResourcePasswordRequest{
ResourceRequestBase: ResourceRequestBase{
RequestBase: RequestBase{
SSO: "testuser",
SSO: testUser,
},
ResourceID: id,
},
......@@ -733,7 +732,7 @@ func TestService_ResetResourcePassword(t *testing.T) {
// // Bad recovery response.
// _, err := svc.RecoverPassword(context.Background(), tx, &AccountRecoveryRequest{
// Username: "testuser",
// Username: testUser,
// RecoveryPassword: "BADPASS",
// Password: "new_password",
// })
......@@ -743,7 +742,7 @@ func TestService_ResetResourcePassword(t *testing.T) {
// // Successful account recovery.
// _, err = svc.RecoverPassword(context.Background(), tx, &AccountRecoveryRequest{
// Username: "testuser",
// Username: testUser,
// RecoveryPassword: "recoverypassword",
// Password: "new_password",
// })
......
......@@ -111,14 +111,12 @@ func TestIntegration_CreateResources(t *testing.T) {
for _, td := range testdata {
err := c.request("/api/resource/create", &as.CreateResourcesRequest{
AdminUserRequestBase: as.AdminUserRequestBase{
UserRequestBase: as.UserRequestBase{
RequestBase: as.RequestBase{
SSO: c.ssoTicket(testAdminUser),
},
Username: "uno@investici.org",
AdminRequestBase: as.AdminRequestBase{
RequestBase: as.RequestBase{
SSO: c.ssoTicket(testAdminUser),
},
},
Username: "uno@investici.org",
Resources: []*as.Resource{td.resource},
}, nil)
if err == nil && !td.expectedOk {
......@@ -165,6 +163,29 @@ func TestIntegration_CreateMultipleResources(t *testing.T) {
true,
},
{
// Same as above, but without an associated user.
"site_with_db_no_user",
"",
[]*as.Resource{
&as.Resource{
ID: as.ResourceID("example3_site"),
Type: as.ResourceTypeDomain,
Name: "example3.com",
},
&as.Resource{
Type: as.ResourceTypeDAV,
Name: "example3dav",
},
&as.Resource{
Type: as.ResourceTypeDatabase,
ParentID: as.ResourceID("example3_site"),
Name: "example3",
},
},
false,
},
{
// An attempt to create resources without client-side
// IDs. It should fail the database validation.
......@@ -228,18 +249,32 @@ func TestIntegration_CreateMultipleResources(t *testing.T) {
},
false,
},
{
// Resource without an owner.
"list",
"",
[]*as.Resource{
&as.Resource{
Type: as.ResourceTypeMailingList,
Name: "list1@example.com",
List: &as.MailingList{
Admins: []string{"uno@investici.org"},
},
},
},
true,
},
}
for _, td := range testdata {
req := &as.CreateResourcesRequest{
AdminUserRequestBase: as.AdminUserRequestBase{
UserRequestBase: as.UserRequestBase{
RequestBase: as.RequestBase{
SSO: c.ssoTicket(testAdminUser),
},
Username: td.username,
AdminRequestBase: as.AdminRequestBase{
RequestBase: as.RequestBase{
SSO: c.ssoTicket(testAdminUser),
},
},
Username: td.username,
Resources: td.resources,
}
err := c.request("/api/resource/create", req, nil)
......
......@@ -126,15 +126,17 @@ func startServiceWithConfig(t testing.TB, svcConfig as.Config) (func(), as.Backe
svcConfig.SSO.AdminGroup = testAdminGroup
svcConfig.ForbiddenUsernames = []string{"forbidden"}
svcConfig.AvailableDomains = map[string][]string{
as.ResourceTypeEmail: []string{"example.com"},
as.ResourceTypeEmail: []string{"example.com"},
as.ResourceTypeMailingList: []string{"example.com"},
}
shards := []string{"host1", "host2", "host3"}
svcConfig.Shards.Available = map[string][]string{
as.ResourceTypeEmail: shards,
as.ResourceTypeWebsite: shards,
as.ResourceTypeDomain: shards,
as.ResourceTypeDAV: shards,
as.ResourceTypeDatabase: shards,
as.ResourceTypeEmail: shards,
as.ResourceTypeMailingList: shards,
as.ResourceTypeWebsite: shards,
as.ResourceTypeDomain: shards,
as.ResourceTypeDAV: shards,
as.ResourceTypeDatabase: shards,
}
svcConfig.Shards.Allowed = svcConfig.Shards.Available
svcConfig.WebsiteRootDir = "/home/users/investici.org"
......
......@@ -36,7 +36,6 @@ uidNumber: 19475
host: host2
mailAlternateAddress: alias@example.com
recoverAnswer: {crypt}$1$wtEa4TKB$lxeyenkQ1yfxECn7WVQQ0/
gidNumber: 2000
mail: uno@investici.org
creationDate: 2002-05-07
mailMessageStore: investici.org/uno/
......@@ -76,6 +75,7 @@ parentSite: autistici.org
objectClass: top
objectClass: subSite
alias: uno
uidNumber: 19475
host: host2
documentRoot: /home/users/investici.org/uno/html-uno
creationDate: 01-08-2013
......@@ -89,6 +89,7 @@ objectClass: top
objectClass: virtualHost
cn: example.com
host: host2
uidNumber: 19475
documentRoot: /home/users/investici.org/uno/html-example.com
creationDate: 02-08-2013
originalHost: host2
......
......@@ -75,6 +75,7 @@ documentRoot: /home/users/investici.org/due/html-due
creationDate: 01-08-2013
originalHost: host2
statsId: 2193
uidNumber: 256799
option: php
dn: dbname=due,alias=due,uid=due@investici.org,ou=People,dc=example,dc=com
......
......@@ -5,7 +5,6 @@ import (
"context"
"errors"
"fmt"
"log"
"math/rand"
"os"
"path/filepath"
......@@ -396,6 +395,9 @@ func (v *validationContext) validateShardedResource(ctx context.Context, r *Reso
if err := v.validateResource(ctx, r, user); err != nil {
return err
}
if r.Shard == "" {
return errors.New("empty shard")
}
if !v.shards.IsAllowedShard(ctx, r.Type, r.Shard) {
return fmt.Errorf(
"invalid shard %s for resource type %s (allowed: %v)",
......@@ -459,7 +461,7 @@ func (v *validationContext) validListResource() ResourceValidatorFunc {
func findMatchingDAVAccount(user *User, r *Resource) *Resource {
for _, dav := range user.GetResourcesByType(ResourceTypeDAV) {
if isSubdir(dav.DAV.Homedir, r.Website.DocumentRoot) {
return r
return dav
}
}
return nil
......@@ -661,48 +663,92 @@ func newFieldValidators(v *validationContext) *fieldValidators {
// UserValidatorFunc is a compound validator for User objects.
type UserValidatorFunc func(context.Context, *User) error
// A custom validator for User objects.
// 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.
func (v *validationContext) validUser() UserValidatorFunc {
nameValidator := v.validHostedEmail()
return func(ctx context.Context, user *User) error {
return nameValidator(ctx, user.Name)
if err := nameValidator(ctx, user.Name); err != nil {
return err
}
return checkUserInvariants(user)
}
}
// 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 {
func (c *templateContext) pickShard(ctx context.Context, r *Resource) (string, error) {
avail := c.shards.GetAvailableShards(ctx, r.Type)
if len(avail) == 0 {
return ""
return "", fmt.Errorf("no available shards for resource type %s", r.Type)
}
return avail[rand.Intn(len(avail))]
return avail[rand.Intn(len(avail))], nil
}
func (c *templateContext) setResourceShard(ctx context.Context, r *Resource, ref *Resource) {
func (c *templateContext) setResourceShard(ctx context.Context, r *Resource, ref *Resource) error {
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)
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) {
......@@ -711,38 +757,57 @@ func (c *templateContext) setResourceStatus(r *Resource) {
}
}
func (c *templateContext) emailResourceTemplate(ctx context.Context, r *Resource, user *User) {
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 {
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
c.setResourceShard(ctx, r, nil)
c.setResourceStatus(r)
return c.setCommonResourceAttrs(ctx, r, nil, nil)
}
func (c *templateContext) websiteResourceTemplate(ctx context.Context, r *Resource, user *User) {
if r.Website == nil {
r.Website = new(Website)
// 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")
}
// For sub-sites, parse ParentDomain from the resource name.
name := r.Name
if r.Type == ResourceTypeWebsite {
nameParts := strings.SplitN(name, "/", 2)
r.Website.ParentDomain = nameParts[0]
name = nameParts[1]
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 == "" {
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)
dav := user.GetSingleResourceByType(ResourceTypeDAV)
if dav == nil {
return errors.New("user has no DAV accounts")
}