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
This diff is collapsed.
......@@ -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,
},
}
for _, td := range testdata {
err := c.request("/api/resource/create", &accountserver.CreateResourcesRequest{
SSO: c.ssoTicket(testAdminUser),
Resources: []*accountserver.Resource{td.resource},
}, nil)
if err == nil && !td.expectedOk {
t.Errorf("CreateResource(%s) should have failed but didn't", td.resource.ID)
} else if err != nil && td.expectedOk {
t.Errorf("CreateResource(%s) failed: %v", td.resource.ID, err)
}
}
}
func TestIntegration_CreateMultipleResources(t *testing.T) {
stop, c := startService(t)
defer stop()
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",
},
},
&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",
},
},
},
}, nil)
if err != nil {
t.Fatal("CreateResourcesRequest", err)
t.Errorf("CreateResources failed: %v", err)
}
}
......@@ -265,9 +265,14 @@ type hasValidate interface {
Validate(context.Context, *AccountService) error
}
type hasCompoundValidate interface {
Validate(context.Context, *AccountService, *User) error
}
// Wrapper for actions that sets up some request-related parameters
// (mostly in the Context, used for later logging).
func (s *AccountService) withRequest(ctx context.Context, req interface{}, f func(context.Context) error) error {
// (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 rnc, ok := req.(hasNewContext); ok {
ctx = rnc.NewContext(ctx)
}
......@@ -275,6 +280,10 @@ func (s *AccountService) withRequest(ctx context.Context, req interface{}, f fun
if err := rv.Validate(ctx, s); err != nil {
return newRequestError(err)
}
} else if cv, ok := req.(hasCompoundValidate); ok {
if err := cv.Validate(ctx, s, user); err != nil {
return newRequestError(err)
}
}
err := f(ctx)
......
......@@ -34,6 +34,17 @@ type User struct {
Resources []*Resource `json:"resources,omitempty"`
}
// GetResourceByID returns the resource with the specified ID, or nil
// if not found.
func (u *User) GetResourceByID(id ResourceID) *Resource {
for _, r := range u.Resources {
if r.ID.Equal(id) {
return r
}
}
return nil
}
// GetResourcesByType returns all resources with the specified type.
func (u *User) GetResourcesByType(resourceType string) []*Resource {
var out []*Resource
......@@ -99,6 +110,7 @@ const (
const (
ResourceStatusActive = "active"
ResourceStatusInactive = "inactive"
ResourceStatusReadonly = "readonly"
)
// ResourceID is a a unique primary key in the resources space, with a
......@@ -114,6 +126,19 @@ func NewResourceID(p ...string) ResourceID {
return ResourceID{Parts: p}
}
// Equal returns true if the two IDs are the same.
func (i ResourceID) Equal(other ResourceID) bool {
if len(i.Parts) != len(other.Parts) {
return false
}
for idx := 0; idx < len(i.Parts); idx++ {
if i.Parts[idx] != other.Parts[idx] {
return false
}
}
return true
}
// Empty returns true if the ResourceID has the nil value.
func (i ResourceID) Empty() bool {
return len(i.Parts) == 0
......
......@@ -18,14 +18,25 @@ type domainBackend interface {
IsAllowedDomain(context.Context, string, string) bool
}
// 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
}
// The validationContext contains all configuration and backends that
// the various validation functions will need.
// 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 {
forbiddenUsernames stringSet
forbiddenPasswords stringSet
minPasswordLength int
maxPasswordLength int
domains domainBackend
shards shardBackend
backend Backend
}
......@@ -72,6 +83,23 @@ func (d *staticDomainBackend) IsAllowedDomain(_ context.Context, kind, domain st
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)
if err != nil {
......@@ -329,20 +357,101 @@ func allOf(funcs ...ValidatorFunc) ValidatorFunc {
// ResourceValidatorFunc is a composite type validator that checks
// various fields in a Resource, depending on its type.
type ResourceValidatorFunc func(ctx context.Context, r *Resource) error
type ResourceValidatorFunc func(context.Context, *Resource, *User) error
func (v *validationContext) validateResource(_ context.Context, r *Resource, user *User) error {
// Resource name must match the name in the resource ID
// (until we get rid of the Name field).
if r.Name != r.ID.Name() {
return errors.New("mismatched ID and name")
}
// Validate the status enum.
switch r.Status {
case ResourceStatusActive, ResourceStatusInactive, ResourceStatusReadonly:
default:
return errors.New("unknown resource status")
}
// Ensure that, if the resource has an user, it is the given user.
u := r.ID.User()
switch {
case u == "" && user != nil:
return fmt.Errorf("attempt to modify global resource in user context (user=%s)", user.Name)
case u != "" && user == nil:
return errors.New("resource ID has user but no user in context")
case user != nil && u != user.Name:
return errors.New("can't modify resource owned by another user")
}
// 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
}
if !v.shards.IsAllowedShard(ctx, r.ID.Type(), r.Shard) {
return fmt.Errorf(
"invalid shard %s for resource type %s (allowed: %v)",
r.Shard,
r.ID.Type(),
v.shards.GetAllowedShards(ctx, r.ID.Type()),
)
}
if r.OriginalShard == "" {
return errors.New("empty original_shard")
}
return nil
}
func (v *validationContext) validEmailResource() ResourceValidatorFunc {
emailValidator := v.validHostedEmail()
return func(ctx context.Context, r *Resource) error {
return emailValidator(ctx, r.ID.Name())
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")
}
if err := emailValidator(ctx, r.ID.Name()); err != nil {
return err
}
if r.Email.Maildir == "" {
return errors.New("empty maildir")
}
return nil
}
}
func (v *validationContext) validListResource() ResourceValidatorFunc {
listValidator := v.validHostedMailingList()
return func(ctx context.Context, r *Resource) error {
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")
}
if err := listValidator(ctx, r.ID.Name()); err != nil {
return err
}
......@@ -353,6 +462,15 @@ func (v *validationContext) validListResource() ResourceValidatorFunc {
}
}
func hasMatchingDAVAccount(user *User, r *Resource) bool {
for _, dav := range user.GetResourcesByType(ResourceTypeDAV) {
if strings.HasPrefix(r.Website.DocumentRoot, dav.DAV.Homedir+"/") {
return true
}
}
return false
}
func (v *validationContext) validDomainResource() ResourceValidatorFunc {
domainValidator := allOf(
minLength(6),
......@@ -360,8 +478,34 @@ func (v *validationContext) validDomainResource() ResourceValidatorFunc {
v.isAvailableDomain(),
)
return func(ctx context.Context, r *Resource) error {
return domainValidator(ctx, r.ID.Name())
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")
}
if err := domainValidator(ctx, r.ID.Name()); err != nil {
return err
}
if r.Website.ParentDomain != "" {
return errors.New("non-empty parent_domain on domain resource")
}
// Document root checks.
if r.Website.DocumentRoot == "" {
return errors.New("empty document_root")
}
if !hasMatchingDAVAccount(user, r) {
return errors.New("website has no matching DAV account")
}
return nil
}
}
......@@ -373,11 +517,77 @@ func (v *validationContext) validWebsiteResource() ResourceValidatorFunc {
)
parentValidator := v.isAllowedDomain(ResourceTypeWebsite)
return func(ctx context.Context, r *Resource) error {
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")
}
if err := nameValidator(ctx, r.ID.Name()); err != nil {
return err
}
return parentValidator(ctx, r.Website.ParentDomain)
if err := parentValidator(ctx, r.Website.ParentDomain); err != nil {
return err
}
// Document root checks: must not be empty, and the
// user must have a DAV account with a home directory