Commit 78d08eef authored by ale's avatar ale
Browse files

Switch to really opaque ResourceIDs

The new ResourceID is really a database ID (in our case, a LDAP DN),
and we have completely decoupled other request attributes like type
and owner from it.

Resource ownership checks are now delegated to the backend.

Also change the backend CreateResource call to CreateResources, taking
multiple resources at once, so we can perform user-level resource
validation, and simplify the CreateUser code path.
parent c5d3b1a5
......@@ -181,11 +181,11 @@ func (r *ResourceRequestBase) PopulateContext(rctx *RequestContext) error {
if err != nil {
return err
}
rctx.Resource = rsrc
rctx.Resource = &rsrc.Resource
// If the resource has an owner, populate the User context field.
if owner := rsrc.ID.User(); owner != "" {
user, err := getUserOrDie(rctx.Context, rctx.TX, owner)
if rsrc.Owner != "" {
user, err := getUserOrDie(rctx.Context, rctx.TX, rsrc.Owner)
if err != nil {
return err
}
......@@ -197,27 +197,12 @@ func (r *ResourceRequestBase) PopulateContext(rctx *RequestContext) error {
// Authorize the request.
func (r *ResourceRequestBase) Authorize(rctx *RequestContext) error {
if !rctx.isAdmin(rctx.SSO) && !canAccessResource(rctx.SSO.User, rctx.Resource) {
if !rctx.isAdmin(rctx.SSO) && !rctx.TX.CanAccessResource(rctx.Context, rctx.SSO.User, rctx.Resource) {
return fmt.Errorf("user %s can't access resource %s", rctx.SSO.User, rctx.Resource.ID)
}
return nil
}
func canAccessResource(username string, r *Resource) bool {
switch r.ID.Type() {
case ResourceTypeMailingList:
// Check the list owners.
for _, a := range r.List.Admins {
if a == username {
return true
}
}
return false
default:
return r.ID.User() == username
}
}
// AdminResourceRequestBase is an admin-only version of ResourceRequestBase.
type AdminResourceRequestBase struct {
ResourceRequestBase
......
......@@ -3,6 +3,7 @@ package accountserver
import (
"context"
"errors"
"fmt"
"log"
"git.autistici.org/ai3/go-common/pwhash"
......@@ -10,7 +11,7 @@ import (
// CreateResourcesRequest lets administrators create one or more resources.
type CreateResourcesRequest struct {
AdminRequestBase
AdminUserRequestBase
// Resources to create. All must either be global resources
// (no user ownership), or belong to the same user.
......@@ -23,51 +24,43 @@ type CreateResourcesResponse struct {
Resources []*Resource `json:"resources"`
}
func (r *CreateResourcesRequest) getOwner(rctx *RequestContext) (*RawUser, error) {
// 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.
if len(r.Resources) > 0 {
if owner := r.Resources[0].ID.User(); owner != "" {
u, err := getUserOrDie(rctx.Context, rctx.TX, owner)
if err != nil {
return nil, err
// Merge two resource lists by ID, return a new list. If a resource is
// duplicated (detected by type/name matching), return an error.
func mergeResources(a, b []*Resource) ([]*Resource, error) {
tmp := make(map[string]struct{})
var out []*Resource
for _, l := range [][]*Resource{a, b} {
for _, r := range l {
key := r.String()
if _, seen := tmp[key]; seen {
return nil, fmt.Errorf("resource %s already exists", key)
}
user := *u
user.Resources = mergeResources(u.Resources, r.Resources)
return &user, nil
tmp[key] = struct{}{}
out = append(out, r)
}
}
return nil, nil
return out, nil
}
// Validate the request.
func (r *CreateResourcesRequest) Validate(rctx *RequestContext) error {
var owner string
user, err := r.getOwner(rctx)
// 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 user != nil {
owner = user.Name
tplUser = &user.User
}
tplUser := rctx.User.User
tplUser.Resources = merged
for _, rsrc := range r.Resources {
rctx.resourceTemplates.applyTemplate(rctx.Context, rsrc, tplUser)
// Check same-user ownership.
if rsrc.ID.User() != owner {
return errors.New("resources are owned by different users")
}
rctx.resourceTemplates.applyTemplate(rctx.Context, rsrc, &tplUser)
// Validate the resource.
if err := rctx.resourceValidator.validateResource(rctx.Context, rsrc, tplUser); err != nil {
log.Printf("validation error while creating resource %s: %v", rsrc.ID, err)
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
}
}
......@@ -76,30 +69,16 @@ func (r *CreateResourcesRequest) Validate(rctx *RequestContext) error {
// Serve the request.
func (r *CreateResourcesRequest) Serve(rctx *RequestContext) (interface{}, error) {
var resp CreateResourcesResponse
for _, rsrc := range r.Resources {
if err := rctx.TX.CreateResource(rctx.Context, rsrc); err != nil {
return nil, err
}
rctx.audit.Log(rctx, rsrc.ID, "resource created")
resp.Resources = append(resp.Resources, rsrc)
}
return &resp, nil
}
// 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
}
rsrcs, err := rctx.TX.CreateResources(rctx.Context, &rctx.User.User, r.Resources)
if err != nil {
return nil, err
}
out := make([]*Resource, 0, len(tmp))
for _, r := range tmp {
out = append(out, r)
for _, rsrc := range rsrcs {
rctx.audit.Log(rctx, rsrc, "resource created")
}
return out
return &CreateResourcesResponse{
Resources: rsrcs,
}, nil
}
// CreateUserRequest lets administrators create a new user along with the
......@@ -159,7 +138,7 @@ func (r *CreateUserRequest) Validate(rctx *RequestContext) error {
log.Printf("validation error while creating resource %+v: %v", rsrc, err)
return err
}
if rsrc.ID.Type() == ResourceTypeEmail {
if rsrc.Type == ResourceTypeEmail {
emailCount++
}
}
......@@ -200,17 +179,18 @@ func (r *CreateUserRequest) Serve(rctx *RequestContext) (interface{}, error) {
var resp CreateUserResponse
// Create the user first, along with all the resources.
if err := rctx.TX.CreateUser(rctx.Context, r.User); err != nil {
user, err := rctx.TX.CreateUser(rctx.Context, r.User)
if err != nil {
return nil, err
}
resp.User = r.User
resp.User = user
// Now set a password for the user and return it, and
// set random passwords for all the resources
// (currently, we don't care about those, the user
// will reset them later). However, we could return
// them in the response as well, if necessary.
u := &RawUser{User: *r.User}
u := &RawUser{User: *user}
newPassword := randomPassword()
if err := u.resetPassword(rctx.Context, rctx.TX, newPassword); err != nil {
return nil, err
......@@ -219,15 +199,15 @@ func (r *CreateUserRequest) Serve(rctx *RequestContext) (interface{}, error) {
// Fake a RawUser in the RequestContext just for the purpose
// of audit logging.
rctx.User = &RawUser{User: *r.User}
rctx.audit.Log(rctx, ResourceID{}, "user created")
rctx.User = u
rctx.audit.Log(rctx, nil, "user created")
for _, rsrc := range r.User.Resources {
rctx.audit.Log(rctx, rsrc.ID, "resource created")
rctx.audit.Log(rctx, rsrc, "resource created")
if resourceHasPassword(rsrc) {
if _, err := doResetResourcePassword(rctx.Context, rctx.TX, rsrc); err != nil {
// Just log, don't fail.
log.Printf("can't set random password for resource %s: %v", rsrc.ID, err)
log.Printf("can't set random password for resource %s: %v", rsrc.String(), err)
}
}
}
......@@ -235,6 +215,15 @@ func (r *CreateUserRequest) Serve(rctx *RequestContext) (interface{}, error) {
return &resp, nil
}
func resourceHasPassword(r *Resource) bool {
switch r.Type {
case ResourceTypeDAV, ResourceTypeDatabase, ResourceTypeMailingList:
return true
default:
return false
}
}
func doResetResourcePassword(ctx context.Context, tx TX, rsrc *Resource) (string, error) {
newPassword := randomPassword()
encPassword := pwhash.Encrypt(newPassword)
......
......@@ -13,7 +13,7 @@ func setResourceStatus(rctx *RequestContext, status string) error {
if err := rctx.TX.UpdateResource(rctx.Context, rsrc); err != nil {
return err
}
rctx.audit.Log(rctx, rsrc.ID, fmt.Sprintf("status set to %s", status))
rctx.audit.Log(rctx, rsrc, fmt.Sprintf("status set to %s", status))
return nil
}
......@@ -50,18 +50,9 @@ type ResetResourcePasswordResponse struct {
Password string `json:"password"`
}
func resourceHasPassword(r *Resource) bool {
switch r.ID.Type() {
case ResourceTypeDAV, ResourceTypeDatabase, ResourceTypeMailingList:
return true
default:
return false
}
}
// Validate the request.
func (r *ResetResourcePasswordRequest) Validate(_ *RequestContext) error {
switch r.ResourceID.Type() {
func (r *ResetResourcePasswordRequest) Validate(rctx *RequestContext) error {
switch rctx.Resource.Type {
case ResourceTypeDAV, ResourceTypeDatabase, ResourceTypeMailingList:
case ResourceTypeEmail:
return errors.New("can't reset email passwords with this API")
......@@ -118,7 +109,7 @@ func (r *MoveResourceRequest) Serve(rctx *RequestContext) (interface{}, error) {
// We need to enforce consistency between email resources and
// the user shard, so that temporary data can be colocated
// with email storage.
if rctx.Resource.ID.Type() == ResourceTypeEmail && rctx.User.Shard != r.Shard {
if rctx.Resource.Type == ResourceTypeEmail && rctx.User.Shard != r.Shard {
rctx.User.Shard = r.Shard
if err := rctx.TX.UpdateUser(rctx.Context, &rctx.User.User); err != nil {
return nil, err
......@@ -144,7 +135,7 @@ type AddEmailAliasRequest struct {
// Validate the request.
func (r *AddEmailAliasRequest) Validate(rctx *RequestContext) error {
if rctx.Resource.ID.Type() != ResourceTypeEmail {
if rctx.Resource.Type != ResourceTypeEmail {
return errors.New("this operation only works on email resources")
}
......@@ -168,7 +159,7 @@ func (r *AddEmailAliasRequest) Serve(rctx *RequestContext) (interface{}, error)
return nil, err
}
rctx.audit.Log(rctx, r.ResourceID, fmt.Sprintf("added alias %s", r.Addr))
rctx.audit.Log(rctx, rctx.Resource, fmt.Sprintf("added alias %s", r.Addr))
return nil, nil
}
......@@ -180,7 +171,7 @@ type DeleteEmailAliasRequest struct {
// Validate the request.
func (r *DeleteEmailAliasRequest) Validate(rctx *RequestContext) error {
if rctx.Resource.ID.Type() != ResourceTypeEmail {
if rctx.Resource.Type != ResourceTypeEmail {
return errors.New("this operation only works on email resources")
}
return nil
......@@ -199,6 +190,6 @@ func (r *DeleteEmailAliasRequest) Serve(rctx *RequestContext) (interface{}, erro
return nil, err
}
rctx.audit.Log(rctx, r.ResourceID, fmt.Sprintf("removed alias %s", r.Addr))
rctx.audit.Log(rctx, rctx.Resource, fmt.Sprintf("removed alias %s", r.Addr))
return nil, nil
}
......@@ -3,6 +3,8 @@ package accountserver
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"git.autistici.org/ai3/go-common/pwhash"
......@@ -11,7 +13,7 @@ import (
type fakeBackend struct {
users map[string]*User
resources map[string]map[string]*Resource
resources map[string]*Resource
passwords map[string]string
recoveryPasswords map[string]string
appSpecificPasswords map[string][]*AppSpecificPasswordInfo
......@@ -30,8 +32,16 @@ func (b *fakeBackend) NextUID(_ context.Context) (int, error) {
return 42, nil
}
func (b *fakeBackend) CanAccessResource(_ context.Context, username string, rsrc *Resource) bool {
owner := strings.Split(string(rsrc.ID), "/")[0]
return owner == username
}
func (b *fakeBackend) GetUser(_ context.Context, username string) (*RawUser, error) {
u := b.users[username]
u, ok := b.users[username]
if !ok {
return nil, errors.New("user not found in fake backend")
}
return &RawUser{
User: *u,
Password: b.passwords[username],
......@@ -45,27 +55,50 @@ func (b *fakeBackend) UpdateUser(_ context.Context, user *User) error {
return nil
}
func (b *fakeBackend) CreateUser(_ context.Context, user *User) error {
func (b *fakeBackend) CreateUser(_ context.Context, user *User) (*User, error) {
for _, r := range user.Resources {
r.ID = makeResourceID(user.Name, r.Type, r.Name)
}
b.users[user.Name] = user
return nil
return user, nil
}
func (b *fakeBackend) GetResource(_ context.Context, resourceID ResourceID) (*Resource, error) {
return b.resources[resourceID.User()][resourceID.String()], nil
func (b *fakeBackend) GetResource(_ context.Context, resourceID ResourceID) (*RawResource, error) {
owner := strings.Split(resourceID.String(), "/")[0]
r := b.resources[resourceID.String()]
return &RawResource{Resource: *r, Owner: owner}, nil
}
func (b *fakeBackend) UpdateResource(_ context.Context, r *Resource) error {
b.resources[r.ID.User()][r.ID.String()] = r
b.resources[r.ID.String()] = r
return nil
}
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")
}
func makeResourceID(owner, rtype, rname string) ResourceID {
return ResourceID(fmt.Sprintf("%s/%s/%s", owner, rtype, rname))
}
b.resources[r.ID.User()][r.ID.String()] = r
return nil
func (b *fakeBackend) CreateResources(_ context.Context, u *User, rsrcs []*Resource) ([]*Resource, error) {
var out []*Resource
for _, r := range rsrcs {
if !r.ID.Empty() {
return nil, errors.New("resource ID not empty")
}
var username string
if u != nil {
username = u.Name
}
r.ID = makeResourceID(username, r.Type, r.Name)
if _, ok := b.resources[r.ID.String()]; ok {
return nil, errors.New("resource already exists")
}
b.resources[r.ID.String()] = r
out = append(out, r)
}
return out, nil
}
func (b *fakeBackend) SetUserPassword(_ context.Context, user *User, password string) error {
......@@ -123,11 +156,9 @@ func (b *fakeBackend) DeleteUserTOTPSecret(_ context.Context, user *User) error
func (b *fakeBackend) HasAnyResource(_ context.Context, rsrcs []FindResourceRequest) (bool, error) {
for _, fr := range rsrcs {
for _, ur := range b.resources {
for _, r := range ur {
if r.ID.Type() == fr.Type && r.ID.Name() == fr.Name {
return true, nil
}
for _, r := range b.resources {
if r.Type == fr.Type && r.Name == fr.Name {
return true, nil
}
}
}
......@@ -160,23 +191,20 @@ func (v *fakeValidator) Validate(tkt, nonce, service string, _ []string) (*sso.T
func (b *fakeBackend) addUser(user *User, pw, rpw string) {
b.users[user.Name] = user
b.resources[user.Name] = make(map[string]*Resource)
//b.resources[user.Name] = make(map[string]*Resource)
b.passwords[user.Name] = pwhash.Encrypt(pw)
if rpw != "" {
b.recoveryPasswords[user.Name] = pwhash.Encrypt(rpw)
}
for _, r := range user.Resources {
b.resources[user.Name][r.ID.String()] = r
b.resources[r.ID.String()] = r
}
}
func createFakeBackend() *fakeBackend {
fb := &fakeBackend{
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),
},
users: make(map[string]*User),
resources: make(map[string]*Resource),
passwords: make(map[string]string),
recoveryPasswords: make(map[string]string),
appSpecificPasswords: make(map[string][]*AppSpecificPasswordInfo),
......@@ -189,8 +217,9 @@ func createFakeBackend() *fakeBackend {
UID: 4242,
Resources: []*Resource{
{
ID: NewResourceID(ResourceTypeEmail, "testuser", "testuser@example.com"),
ID: makeResourceID("testuser", ResourceTypeEmail, "testuser@example.com"),
Name: "testuser@example.com",
Type: ResourceTypeEmail,
Status: ResourceStatusActive,
Shard: "1",
Email: &Email{
......@@ -198,8 +227,9 @@ func createFakeBackend() *fakeBackend {
},
},
{
ID: NewResourceID(ResourceTypeDAV, "testuser", "dav1"),
ID: makeResourceID("testuser", ResourceTypeDAV, "dav1"),
Name: "dav1",
Type: ResourceTypeDAV,
Status: ResourceStatusActive,
DAV: &WebDAV{
Homedir: "/home/dav1",
......@@ -237,9 +267,7 @@ func testService(admin string) *AccountService {
return svc
}
func TestService_GetUser(t *testing.T) {
svc := testService("")
func getUser(t testing.TB, svc *AccountService, username string) *User {
req := &GetUserRequest{
UserRequestBase: UserRequestBase{
RequestBase: RequestBase{
......@@ -252,8 +280,15 @@ func TestService_GetUser(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if resp.(*User).Name != "testuser" {
t.Fatalf("bad response: %+v", resp)
return resp.(*User)
}
func TestService_GetUser(t *testing.T) {
svc := testService("")
user := getUser(t, svc, "testuser")
if user.Name != "testuser" {
t.Fatalf("bad response: %+v", user)
}
}
......@@ -266,36 +301,41 @@ func TestService_GetUser_ResourceGroups(t *testing.T) {
Status: UserStatusActive,
Resources: []*Resource{
{
ID: NewResourceID(ResourceTypeDAV, "testuser2", "dav1"),
ID: makeResourceID("testuser2", ResourceTypeDAV, "dav1"),
Type: ResourceTypeDAV,
Name: "dav1",
DAV: &WebDAV{
Homedir: "/home/users/investici.org/dav1",
},
},
{
ID: NewResourceID(ResourceTypeDAV, "testuser2", "dav1-domain2"),
ID: makeResourceID("testuser2", ResourceTypeDAV, "dav1-domain2"),
Type: ResourceTypeDAV,
Name: "dav1-domain2",
DAV: &WebDAV{
Homedir: "/home/users/investici.org/dav1/html-domain2.com/subdir",
},
},
{
ID: NewResourceID(ResourceTypeDomain, "testuser2", "domain1.com"),
ID: makeResourceID("testuser2", ResourceTypeDomain, "domain1.com"),
Type: ResourceTypeDomain,
Name: "domain1.com",
Website: &Website{
DocumentRoot: "/home/users/investici.org/dav1/html-domain1.com",
},
},
{
ID: NewResourceID(ResourceTypeDomain, "testuser2", "domain2.com"),
ID: makeResourceID("testuser2", ResourceTypeDomain, "domain2.com"),
Type: ResourceTypeDomain,
Name: "domain2.com",
Website: &Website{
DocumentRoot: "/home/users/investici.org/dav1/html-domain2.com",
},
},
{
ID: NewResourceID(ResourceTypeDatabase, "testuser2", "cn=domain2.com", "db2"),
ParentID: NewResourceID(ResourceTypeDomain, "testuser2", "domain2.com"),
ID: makeResourceID("testuser2", ResourceTypeDatabase, "db2"),
ParentID: makeResourceID("testuser2", ResourceTypeDomain, "domain2.com"),
Type: ResourceTypeDatabase,
Name: "db2",
Database: &Database{},
},
......@@ -318,7 +358,7 @@ func TestService_GetUser_ResourceGroups(t *testing.T) {
var grouped []*Resource
for _, r := range user.Resources {
switch r.ID.Type() {
switch r.Type {
case ResourceTypeWebsite, ResourceTypeDomain, ResourceTypeDAV, ResourceTypeDatabase:
grouped = append(grouped, r)
}
......@@ -456,6 +496,10 @@ func TestService_ChangePassword(t *testing.T) {
func TestService_AddEmailAlias(t *testing.T) {
svc := testService("")
// Find the resource ID.
user := getUser(t, svc, "testuser")
emailID := user.GetSingleResourceByType(ResourceTypeEmail).ID
testdata := []struct {
addr string
expectedOk bool
......@@ -471,7 +515,7 @@ func TestService_AddEmailAlias(t *testing.T) {
RequestBase: RequestBase{
SSO: "testuser",
},
ResourceID: NewResourceID(ResourceTypeEmail, "testuser", "testuser@example.com"),
ResourceID: emailID,
},
Addr: td.addr,
}
......@@ -488,14 +532,17 @@ func TestService_CreateResource(t *testing.T) {
svc := testService("admin")
req := &CreateResourcesRequest{
AdminRequestBase: AdminRequestBase{
RequestBase: RequestBase{
SSO: "admin",
AdminUserRequestBase: AdminUserRequestBase{
UserRequestBase: UserRequestBase{
RequestBase: RequestBase{
SSO: "admin",
},
Username: "testuser",
},
},
Resources: []*Resource{
&Resource{
ID: NewResourceID(ResourceTypeDAV, "testuser", "dav2"),
Type: ResourceTypeDAV,
Name: "dav2",
Status: ResourceStatusActive,
Shard: "host2",
......@@ -508,10 +555,20 @@ func TestService_CreateResource(t *testing.T) {
}
// The request should succeed the first time around.
_, err := svc.Handle(context.TODO(), req)
obj, err := svc.Handle(context.TODO(), req)
if err != nil {
t.Fatal("CreateResources", err)
}
resp := obj.(*CreateResourcesResponse)
// Check that a ResourceID has been set.
if len(resp.Resources) != 1 {
t.Fatalf("bad response, expected 1 resource, got %+v", resp)
}
rsrc := resp.Resources[0]
if rsrc.ID.Empty() {
t.Fatal("Resource ID is empty!")
}
// The object already exists, so the same request should fail now.
_, err = svc.Handle(context.TODO(), req)
......@@ -525,20 +582,23 @@ func TestService_CreateResource_List(t *testing.T) {