Commit e8b91a57 authored by ale's avatar ale

Implement user-level resource validation

By adding a User to the resource validation context, we can implement
more complex checks like verifying that websites have an associated
DAV account, or that the parent resource of a database is actually a
website.
parent ce95ac1c
......@@ -114,7 +114,7 @@ func (s *AccountService) DisableResource(ctx context.Context, tx TX, req *Disabl
if err != nil {
return err
}
return s.withRequest(ctx, req, func(ctx context.Context) error {
return s.withRequest(ctx, req, nil, func(ctx context.Context) error {
return s.setResourceStatus(ctx, tx, r, ResourceStatusInactive)
})
}
......@@ -130,7 +130,7 @@ func (s *AccountService) EnableResource(ctx context.Context, tx TX, req *EnableR
if err != nil {
return err
}
return s.withRequest(ctx, req, func(ctx context.Context) error {
return s.withRequest(ctx, req, nil, func(ctx context.Context) error {
return s.setResourceStatus(ctx, tx, r, ResourceStatusActive)
})
}
......@@ -154,7 +154,7 @@ func (s *AccountService) ChangeUserPassword(ctx context.Context, tx TX, req *Cha
return err
}
return s.withRequest(ctx, req, func(ctx context.Context) error {
return s.withRequest(ctx, req, user, func(ctx context.Context) error {
return s.changeUserPasswordAndUpdateEncryptionKeys(ctx, tx, user, req.CurPassword, req.Password)
})
}
......@@ -184,7 +184,7 @@ func (s *AccountService) RecoverPassword(ctx context.Context, tx TX, req *Passwo
// TODO: authenticate with the secret recovery password.
ctx = context.WithValue(ctx, authUserCtxKey, req.Username)
return s.withRequest(ctx, req, func(ctx context.Context) error {
return s.withRequest(ctx, req, user, func(ctx context.Context) error {
// Change the user password (the recovery password does not change).
return s.changeUserPasswordAndUpdateEncryptionKeys(ctx, tx, user, req.RecoveryPassword, req.Password)
})
......@@ -210,7 +210,7 @@ func (s *AccountService) ResetPassword(ctx context.Context, tx TX, req *ResetPas
return err
}
return s.withRequest(ctx, req, func(ctx context.Context) error {
return s.withRequest(ctx, req, user, func(ctx context.Context) error {
// Disable 2FA.
if err := s.disable2FA(ctx, tx, user); err != nil {
return err
......@@ -242,7 +242,7 @@ func (s *AccountService) SetPasswordRecoveryHint(ctx context.Context, tx TX, req
return err
}
return s.withRequest(ctx, req, func(ctx context.Context) error {
return s.withRequest(ctx, req, user, func(ctx context.Context) error {
// If the encryption keys are not set up yet, use the
// CurPassword to initialize them.
keys, decrypted, err := s.readOrInitializeEncryptionKeys(ctx, tx, user, req.CurPassword, req.CurPassword)
......@@ -367,7 +367,7 @@ func (s *AccountService) CreateApplicationSpecificPassword(ctx context.Context,
}
var resp CreateApplicationSpecificPasswordResponse
err = s.withRequest(ctx, req, func(ctx context.Context) error {
err = s.withRequest(ctx, req, user, func(ctx context.Context) error {
// No application-specific passwords unless 2FA is enabled.
if !user.Has2FA {
return newRequestError(errors.New("2FA is not enabled for this user"))
......@@ -426,7 +426,7 @@ func (s *AccountService) DeleteApplicationSpecificPassword(ctx context.Context,
return err
}
return s.withRequest(ctx, req, func(ctx context.Context) error {
return s.withRequest(ctx, req, user, func(ctx context.Context) error {
if err = tx.DeleteApplicationSpecificPassword(ctx, user, req.AspID); err != nil {
return err
}
......@@ -474,7 +474,7 @@ func (s *AccountService) MoveResource(ctx context.Context, tx TX, req *MoveResou
}
var resp MoveResourceResponse
err = s.withRequest(ctx, req, func(ctx context.Context) error {
err = s.withRequest(ctx, req, user, func(ctx context.Context) error {
// Collect all related resources, as they should all be moved at once.
r, err := tx.GetResource(ctx, req.ResourceID)
......@@ -532,7 +532,7 @@ func (s *AccountService) EnableOTP(ctx context.Context, tx TX, req *EnableOTPReq
}
var resp EnableOTPResponse
err = s.withRequest(ctx, req, func(ctx context.Context) error {
err = s.withRequest(ctx, req, user, func(ctx context.Context) error {
// Replace or initialize the TOTP secret.
if req.TOTPSecret == "" {
req.TOTPSecret, err = generateTOTPSecret()
......@@ -564,7 +564,7 @@ func (s *AccountService) DisableOTP(ctx context.Context, tx TX, req *DisableOTPR
}
// Delete the TOTP secret (if present).
return s.withRequest(ctx, req, func(ctx context.Context) error {
return s.withRequest(ctx, req, user, func(ctx context.Context) error {
if err := tx.DeleteUserTOTPSecret(ctx, user); err != nil {
return newBackendError(err)
}
......@@ -595,7 +595,7 @@ func (s *AccountService) AddEmailAlias(ctx context.Context, tx TX, req *AddEmail
return err
}
return s.withRequest(ctx, req, func(ctx context.Context) error {
return s.withRequest(ctx, req, nil, func(ctx context.Context) error {
// Allow at most 5 aliases.
if len(r.Email.Aliases) >= maxEmailAliases {
return errors.New("too many aliases")
......@@ -632,7 +632,7 @@ func (s *AccountService) DeleteEmailAlias(ctx context.Context, tx TX, req *Delet
return err
}
return s.withRequest(ctx, req, func(ctx context.Context) error {
return s.withRequest(ctx, req, nil, func(ctx context.Context) error {
var aliases []string
for _, a := range r.Email.Aliases {
if a != req.Addr {
......@@ -657,7 +657,30 @@ type CreateResourcesRequest struct {
// CreateResourcesResponse is the response type for AccountService.CreateResources().
type CreateResourcesResponse struct {
IDs []ResourceID `json:"ids"`
// Resources to create. All must either be global resources
// (no user ownership), or belong to the same user.
Resources []*Resource `json:"resources"`
}
// Validate the request.
func (req *CreateResourcesRequest) Validate(ctx context.Context, s *AccountService, user *User) error {
var owner string
if user != nil {
owner = user.Name
}
for _, r := range req.Resources {
// Check same-user ownership.
if r.ID.User() != owner {
return errors.New("resources owned by different users")
}
// Validate the resource.
if err := s.resourceValidator.validateResource(ctx, r, user); err != nil {
log.Printf("validation error while creating resource %+v: %v", r, err)
return err
}
}
return nil
}
// CreateResources can create one or more resources.
......@@ -667,32 +690,99 @@ func (s *AccountService) CreateResources(ctx context.Context, tx TX, req *Create
return nil, err
}
// Fetch the user associated with the first resource (if
// any). Since resource validation might reference other
// resources, we need to provide it with a view of what the
// future resources will be. So we merge the resources from
// the database with those from the request, using a local
// copy of the User object.
var user *User
if len(req.Resources) > 0 {
if username := req.Resources[0].ID.User(); username != "" {
u, err := tx.GetUser(ctx, username)
if err != nil {
return nil, err
}
tmp := *u
tmp.Resources = mergeResources(u.Resources, req.Resources)
user = &tmp
}
}
var resp CreateResourcesResponse
err = s.withRequest(ctx, req, func(ctx context.Context) error {
err = s.withRequest(ctx, req, user, func(ctx context.Context) error {
for _, r := range req.Resources {
if err := s.resourceValidator.validateResource(ctx, r); err != nil {
log.Printf("validation error while creating resource %+v: %v", r, err)
return err
}
if err := tx.CreateResource(ctx, r); err != nil {
return err
}
resp.IDs = append(resp.IDs, r.ID)
resp.Resources = append(resp.Resources, r)
}
return nil
})
return &resp, err
}
// Merge two resource lists by ID (the second one wins), return a new list.
func mergeResources(a, b []*Resource) []*Resource {
tmp := make(map[string]*Resource)
for _, l := range [][]*Resource{a, b} {
for _, r := range l {
tmp[r.ID.String()] = r
}
}
out := make([]*Resource, 0, len(tmp))
for _, r := range tmp {
out = append(out, r)
}
return out
}
// CreateUserRequest is the request type for AccountService.CreateUser().
type CreateUserRequest struct {
SSO string `json:"sso"`
User *User `json:"user"`
}
// Validate the request.
func (req *CreateUserRequest) Validate(ctx context.Context, s *AccountService, user *User) error {
// Override server-generated values.
fillUserTemplate(req.User)
// Validate the user *and* all resources.
if err := s.userValidator(ctx, req.User); err != nil {
log.Printf("validation error while creating user %+v: %v", req.User, err)
return err
}
for _, r := range req.User.Resources {
if err := s.resourceValidator.validateResource(ctx, r, req.User); err != nil {
log.Printf("validation error while creating resource %+v: %v", r, err)
return err
}
}
return nil
}
// CreateUserResponse is the request type for AccountService.CreateUser().
type CreateUserResponse struct {
User *User `json:"user,omitempty"`
}
// Make sure that only user-settable fields are set in the User in a
// CreateUserRequest.
func fillUserTemplate(user *User) {
// Some fields should be unset because there are specific
// methods to modify those attributes.
user.Has2FA = false
user.HasEncryptionKeys = false
user.PasswordRecoveryHint = ""
user.AppSpecificPasswords = nil
if user.Lang == "" {
user.Lang = "en"
}
}
// CreateUser creates a new user along with the associated resources.
func (s *AccountService) CreateUser(ctx context.Context, tx TX, req *CreateUserRequest) (*CreateUserResponse, error) {
ctx, err := s.authorizeAdminGeneric(ctx, tx, req.SSO)
if err != nil {
......@@ -700,19 +790,7 @@ func (s *AccountService) CreateUser(ctx context.Context, tx TX, req *CreateUserR
}
var resp CreateUserResponse
err = s.withRequest(ctx, req, func(ctx context.Context) error {
// Validate the user *and* all resources.
if err := s.userValidator(ctx, req.User); err != nil {
log.Printf("validation error while creating user %+v: %v", req.User, err)
return err
}
for _, r := range req.User.Resources {
if err := s.resourceValidator.validateResource(ctx, r); err != nil {
log.Printf("validation error while creating resource %+v: %v", r, err)
return err
}
}
err = s.withRequest(ctx, req, req.User, func(ctx context.Context) error {
if err := tx.CreateUser(ctx, req.User); err != nil {
return err
}
......
......@@ -2,6 +2,7 @@ package accountserver
import (
"context"
"errors"
"testing"
sso "git.autistici.org/id/go-sso"
......@@ -42,6 +43,10 @@ func (b *fakeBackend) UpdateResource(_ context.Context, r *Resource) error {
}
func (b *fakeBackend) CreateResource(_ context.Context, r *Resource) error {
if _, ok := b.resources[r.ID.User()][r.ID.String()]; ok {
return errors.New("resource already exists")
}
b.resources[r.ID.User()][r.ID.String()] = r
return nil
}
......@@ -133,8 +138,11 @@ func (b *fakeBackend) addUser(user *User) {
func createFakeBackend() *fakeBackend {
fb := &fakeBackend{
users: make(map[string]*User),
resources: make(map[string]map[string]*Resource),
users: make(map[string]*User),
resources: map[string]map[string]*Resource{
// For global (user-less) resources, where CreateUser is not called.
"": make(map[string]*Resource),
},
passwords: make(map[string]string),
appSpecificPasswords: make(map[string][]*AppSpecificPasswordInfo),
encryptionKeys: make(map[string][]*UserEncryptionKey),
......@@ -146,7 +154,17 @@ func createFakeBackend() *fakeBackend {
ID: NewResourceID(ResourceTypeEmail, "testuser", "testuser@example.com"),
Name: "testuser@example.com",
Status: ResourceStatusActive,
Email: &Email{},
Email: &Email{
Maildir: "example.com/testuser",
},
},
{
ID: NewResourceID(ResourceTypeDAV, "testuser", "dav1"),
Name: "dav1",
Status: ResourceStatusActive,
DAV: &WebDAV{
Homedir: "/home/dav1",
},
},
},
})
......@@ -157,11 +175,20 @@ func testConfig() *Config {
var c Config
c.ForbiddenUsernames = []string{"root"}
c.AvailableDomains = map[string][]string{
ResourceTypeEmail: []string{"example.com"},
ResourceTypeEmail: []string{"example.com"},
ResourceTypeMailingList: []string{"example.com"},
}
c.SSO.Domain = "mydomain"
c.SSO.Service = "service/"
c.SSO.AdminGroup = testAdminGroupName
c.Shards.Available = map[string][]string{
ResourceTypeEmail: []string{"host1", "host2", "host3"},
ResourceTypeMailingList: []string{"host1", "host2", "host3"},
ResourceTypeWebsite: []string{"host1", "host2", "host3"},
ResourceTypeDomain: []string{"host1", "host2", "host3"},
ResourceTypeDAV: []string{"host1", "host2", "host3"},
}
c.Shards.Allowed = c.Shards.Available
return &c
}
......@@ -328,20 +355,19 @@ func TestService_AddEmailAlias(t *testing.T) {
}
func TestService_CreateResource(t *testing.T) {
svc, tx := testService("")
svc, tx := testService("admin")
req := &CreateResourcesRequest{
SSO: "admin",
Resources: []*Resource{
&Resource{
ID: NewResourceID(ResourceTypeDomain, "testuser", "example2.com"),
Name: "example2.com",
ID: NewResourceID(ResourceTypeDAV, "testuser", "dav2"),
Name: "dav2",
Status: ResourceStatusActive,
Shard: "host2",
OriginalShard: "host2",
Website: &Website{
URL: "https://example2.com",
DocumentRoot: "/home/sites/example2.com",
AcceptMail: true,
DAV: &WebDAV{
Homedir: "/home/dav2",
},
},
},
......@@ -360,23 +386,49 @@ func TestService_CreateResource(t *testing.T) {
}
}
func TestService_CreateResource_List(t *testing.T) {
svc, tx := testService("admin")
// A list is an example of a user-less (global) resource.
req := &CreateResourcesRequest{
SSO: "admin",
Resources: []*Resource{
&Resource{
ID: NewResourceID(ResourceTypeMailingList, "list@example.com"),
Name: "list@example.com",
Status: ResourceStatusActive,
Shard: "host2",
OriginalShard: "host2",
List: &MailingList{
Admins: []string{"testuser@example.com"},
},
},
},
}
// The request should succeed.
_, err := svc.CreateResources(context.Background(), tx, req)
if err != nil {
t.Fatal("CreateResources", err)
}
}
func TestService_CreateUser(t *testing.T) {
svc, tx := testService("")
svc, tx := testService("admin")
req := &CreateUserRequest{
SSO: "admin",
User: &User{
Name: "testuser2@example.com",
Resources: []*Resource{
&Resource{
ID: NewResourceID(ResourceTypeDomain, "testuser2@example.com", "example2.com"),
Name: "example2.com",
ID: NewResourceID(ResourceTypeEmail, "testuser2@example.com", "testuser2@example.com"),
Name: "testuser2@example.com",
Status: ResourceStatusActive,
Shard: "host2",
OriginalShard: "host2",
Website: &Website{
URL: "https://example2.com",
DocumentRoot: "/home/sites/example2.com",
AcceptMail: true,
Email: &Email{
Maildir: "example.com/testuser2",
},
},
},
......@@ -392,3 +444,32 @@ func TestService_CreateUser(t *testing.T) {
t.Fatalf("unexpected user in response: got %s, expected testuser2", resp.User.Name)
}
}
func TestService_CreateUser_FailIfNotAdmin(t *testing.T) {
svc, tx := testService("admin")
req := &CreateUserRequest{
SSO: "testuser",
User: &User{
Name: "testuser2@example.com",
Resources: []*Resource{
&Resource{
ID: NewResourceID(ResourceTypeEmail, "testuser2@example.com", "testuser2@example.com"),
Name: "testuser2@example.com",
Status: ResourceStatusActive,
Shard: "host2",
OriginalShard: "host2",
Email: &Email{
Maildir: "example.com/testuser2",
},
},
},
},
}
// The request should succeed the first time around.
_, err := svc.CreateUser(context.Background(), tx, req)
if err == nil {
t.Fatal("CreateResources did not fail")
}
}
......@@ -14,6 +14,11 @@ type Config struct {
ForbiddenPasswordsFile string `yaml:"forbidden_passwords_file"`
AvailableDomains map[string][]string `yaml:"available_domains"`
Shards struct {
Available map[string][]string `yaml:"available"`
Allowed map[string][]string `yaml:"allowed"`
} `yaml:"shards"`
SSO struct {
PublicKeyFile string `yaml:"public_key"`
Domain string `yaml:"domain"`
......@@ -31,6 +36,21 @@ func (c *Config) domainBackend() domainBackend {
return b
}
func (c *Config) shardBackend() shardBackend {
b := &staticShardBackend{
available: make(map[string]stringSet),
allowed: make(map[string]stringSet),
}
loadSet := func(target map[string]stringSet, src map[string][]string) {
for kind, list := range src {
target[kind] = newStringSetFromList(list)
}
}
loadSet(b.available, c.Shards.Available)
loadSet(b.allowed, c.Shards.Allowed)
return b
}
func (c *Config) validationContext(be Backend) (*validationContext, error) {
fu, err := newStringSetFromFileOrList(c.ForbiddenUsernames, c.ForbiddenUsernamesFile)
if err != nil {
......@@ -46,6 +66,7 @@ func (c *Config) validationContext(be Backend) (*validationContext, error) {
minPasswordLength: 6,
maxPasswordLength: 128,
domains: c.domainBackend(),
shards: c.shardBackend(),
backend: be,
}, nil
}
......
......@@ -3,6 +3,7 @@ package integrationtest
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
......@@ -77,13 +78,19 @@ func (c *testClient) request(uri string, req, out interface{}) error {
return err
}
defer resp.Body.Close()
data, _ = ioutil.ReadAll(resp.Body)
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
log.Printf("request error: %s", string(data))
return errors.New(string(data))
}
if resp.StatusCode != 200 {
log.Printf("remote error: %s", string(data))
return fmt.Errorf("http status code %d", resp.StatusCode)
}
if resp.Header.Get("Content-Type") != "application/json" {
return fmt.Errorf("unexpected content-type %s", resp.Header.Get("Content-Type"))
}
data, _ = ioutil.ReadAll(resp.Body)
log.Printf("response:\n%s\n", string(data))
......@@ -116,6 +123,13 @@ func startService(t testing.TB) (func(), *testClient) {
svcConfig.AvailableDomains = map[string][]string{
accountserver.ResourceTypeEmail: []string{"example.com"},
}
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"},
}
svcConfig.Shards.Allowed = svcConfig.Shards.Available
service, err := accountserver.NewAccountService(be, &svcConfig)
if err != nil {
......@@ -235,9 +249,12 @@ func TestIntegration_CreateResource(t *testing.T) {
stop, c := startService(t)
defer stop()
err := c.request("/api/resource/create", &accountserver.CreateResourcesRequest{
SSO: c.ssoTicket(testAdminUser),
Resources: []*accountserver.Resource{
testdata := []struct {
resource *accountserver.Resource
expectedOk bool
}{
// Create a domain resource.
{
&accountserver.Resource{
ID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example2.com"),
Name: "example2.com",
......@@ -246,13 +263,137 @@ func TestIntegration_CreateResource(t *testing.T) {
OriginalShard: "host2",
Website: &accountserver.Website{
URL: "https://example2.com",
DocumentRoot: "/home/sites/example2.com",
DocumentRoot: "/home/users/investici.org/uno/html-example2.com",
AcceptMail: true,
},
},
true,
},
// Duplicate of the above request, should fail due to conflict.
{
&accountserver.Resource{
ID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example2.com"),
Name: "example2.com",
Status: accountserver.ResourceStatusActive,
Shard: "host2",
OriginalShard: "host2",
Website: &accountserver.Website{
URL: "https://example2.com",
DocumentRoot: "/home/users/investici.org/uno/html-example2.com",
},
},
false,
},
// Malformed website metadata (empty document root).
{
&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",
},
},
false,
},
// Malformed resource metadata (name fails validation).
{
&accountserver.Resource{
ID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example$.com"),
Name: "example$.com",
Status: accountserver.ResourceStatusActive,
Shard: "host2",
OriginalShard: "host2",
Website: &accountserver.Website{
URL: "https://example$.com",
DocumentRoot: "/home/users/investici.org/uno/html-example3.com",
},
},
false,
},
// Bad shard.
{
&accountserver.Resource{
ID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"),
Name: "example3.com",
Status: accountserver.ResourceStatusActive,
Shard: "zebra",
OriginalShard: "zebra",
Website: &accountserver.Website{
URL: "https://example3.com",
DocumentRoot: "/home/users/investici.org/uno/html-example3.com",
},
},
false,
},
// The document root has no associated DAV account.
{
&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",
},
},
false,
},