Commit 78d08eef authored by ale's avatar ale

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
}
This diff is collapsed.
......@@ -58,7 +58,7 @@ func (r *ChangeUserPasswordRequest) Serve(rctx *RequestContext) (interface{}, er
return nil, err
}
rctx.audit.Log(rctx, ResourceID{}, "password changed (user)")
rctx.audit.Log(rctx, nil, "password changed (user)")
rctx.logUserAction(&rctx.User.User, umdb.LogTypePasswordChange, "password changed (user)")
return nil, err
}
......@@ -146,7 +146,7 @@ func (r *AccountRecoveryRequest) Serve(rctx *RequestContext) (interface{}, error
if err := rctx.User.disable2FA(rctx.Context, rctx.TX); err != nil {
return nil, err
}
rctx.audit.Log(rctx, ResourceID{}, "password changed (account recovery)")
rctx.audit.Log(rctx, nil, "password changed (account recovery)")
rctx.logUserAction(&rctx.User.User, umdb.LogTypePasswordReset, "password changed (account recovery)")
return nil, nil
......@@ -184,7 +184,7 @@ func (r *ResetPasswordRequest) Serve(rctx *RequestContext) (interface{}, error)
if err := rctx.User.disable2FA(rctx.Context, rctx.TX); err != nil {
return nil, err
}
rctx.audit.Log(rctx, ResourceID{}, "password changed (admin)")
rctx.audit.Log(rctx, nil, "password changed (admin)")
rctx.logUserAction(&rctx.User.User, umdb.LogTypePasswordReset, "password changed (admin)")
return nil, nil
}
......@@ -333,7 +333,7 @@ func (r *EnableOTPRequest) Serve(rctx *RequestContext) (interface{}, error) {
return nil, err
}
rctx.audit.Log(rctx, ResourceID{}, "totp enabled")
rctx.audit.Log(rctx, nil, "totp enabled")
return &EnableOTPResponse{
TOTPSecret: secret,
......@@ -350,7 +350,7 @@ func (r *DisableOTPRequest) Serve(rctx *RequestContext) (interface{}, error) {
if err := rctx.User.disableOTP(rctx.Context, rctx.TX); err != nil {
return nil, err
}
rctx.audit.Log(rctx, ResourceID{}, "totp disabled")
rctx.audit.Log(rctx, nil, "totp disabled")
return nil, nil
}
......
......@@ -6,7 +6,7 @@ import (
)
type auditLogger interface {
Log(*RequestContext, ResourceID, string)
Log(*RequestContext, *Resource, string)
}
type auditLogEntry struct {
......@@ -20,7 +20,7 @@ type auditLogEntry struct {
type syslogAuditLogger struct{}
func (l *syslogAuditLogger) Log(rctx *RequestContext, rid ResourceID, what string) {
func (l *syslogAuditLogger) Log(rctx *RequestContext, rsrc *Resource, what string) {
e := auditLogEntry{
Message: what,
Comment: rctx.Comment,
......@@ -33,16 +33,16 @@ func (l *syslogAuditLogger) Log(rctx *RequestContext, rid ResourceID, what strin
}
// Fall back to resource from context if unspecified.
if rid.Empty() && rctx.Resource != nil {
rid = rctx.Resource.ID
if rsrc == nil && rctx.Resource != nil {
rsrc = rctx.Resource
}
if !rid.Empty() {
e.ResourceName = rid.Name()
e.ResourceType = rid.Type()
if rsrc != nil {
e.ResourceName = rsrc.Name
e.ResourceType = rsrc.Type
// TODO: redundant?
if u := rid.User(); u != "" {
e.User = u
}
//if u := rid.User(); u != "" {
// e.User = u
//}
}
if data, err := json.Marshal(&e); err == nil {
......
......@@ -78,37 +78,31 @@ func NewLDAPBackend(uri, bindDN, bindPw, base string) (as.Backend, error) {
return newLDAPBackendWithConn(pool, base)
}
func newLDAPBackendWithConn(conn ldapConn, base string) (*backend, error) {
rsrc := newResourceRegistry()
rsrc.register(as.ResourceTypeEmail, &emailResourceHandler{baseDN: base})
rsrc.register(as.ResourceTypeMailingList, &mailingListResourceHandler{baseDN: base})
rsrc.register(as.ResourceTypeDAV, &webdavResourceHandler{baseDN: base})
rsrc.register(as.ResourceTypeWebsite, &websiteResourceHandler{baseDN: base})
rsrc.register(as.ResourceTypeDomain, &domainResourceHandler{baseDN: base})
rsrc.register(as.ResourceTypeDatabase, &databaseResourceHandler{baseDN: base})
func newLDAPBackendWithConn(conn ldapConn, baseDN string) (*backend, error) {
reg := newDefaultResourceRegistry(baseDN)
return &backend{
conn: conn,
baseDN: base,
baseDN: baseDN,
userQuery: &queryTemplate{
Base: joinDN("uid=${user}", "ou=People", base),
Base: joinDN("uid=${user}", "ou=People", baseDN),
Filter: "(objectClass=*)",
Scope: ldap.ScopeBaseObject,
},
userResourceQueries: []*queryTemplate{
// Find all resources that are children of the main uid object.
&queryTemplate{
Base: joinDN("uid=${user}", "ou=People", base),
Base: joinDN("uid=${user}", "ou=People", baseDN),
Scope: ldap.ScopeWholeSubtree,
},
// Find mailing lists, which are nested under a different root.
&queryTemplate{
Base: joinDN("ou=Lists", base),
Base: joinDN("ou=Lists", baseDN),
Filter: "(&(objectClass=mailingList)(listOwner=${user}))",
Scope: ldap.ScopeSingleLevel,
},
},
resources: rsrc,
resources: reg,
minUID: defaultMinUID,
maxUID: defaultMaxUID,
}, nil
......@@ -207,11 +201,15 @@ func encodeU2FRegistrations(regs []*as.U2FRegistration) []string {
}
func (tx *backendTX) getUserDN(user *as.User) string {
return joinDN("uid="+user.Name, "ou=People", tx.backend.baseDN)
return getUserDN(user, tx.backend.baseDN)
}
func getUserDN(user *as.User, baseDN string) string {
return joinDN("uid="+user.Name, "ou=People", baseDN)
}
// CreateUser creates a new user.
func (tx *backendTX) CreateUser(ctx context.Context, user *as.User) error {
func (tx *backendTX) CreateUser(ctx context.Context, user *as.User) (*as.User, error) {
dn := tx.getUserDN(user)
tx.create(dn)
......@@ -220,13 +218,13 @@ func (tx *backendTX) CreateUser(ctx context.Context, user *as.User) error {
}
// Create all resources.
for _, r := range user.Resources {
if err := tx.CreateResource(ctx, r); err != nil {
return err
}
rsrcs, err := tx.CreateResources(ctx, user, user.Resources)
if err != nil {
return nil, err
}
user.Resources = rsrcs
return nil
return user, nil
}
// UpdateUser updates values for the user only (not the resources).
......@@ -456,7 +454,7 @@ func (tx *backendTX) HasAnyResource(ctx context.Context, resourceIDs []as.FindRe
}
// GetResource returns a ResourceWrapper, as part of a read-modify-update transaction.
func (tx *backendTX) GetResource(ctx context.Context, rsrcID as.ResourceID) (*as.Resource, error) {
func (tx *backendTX) GetResource(ctx context.Context, rsrcID as.ResourceID) (*as.RawResource, error) {
// From the resource ID we can obtain the DN, and fetch it
// straight from LDAP without even doing a real search.
dn, err := tx.backend.resources.GetDN(rsrcID)
......@@ -485,25 +483,87 @@ func (tx *backendTX) GetResource(ctx context.Context, rsrcID as.ResourceID) (*as
return nil, err
}
// We know the resource type so we don't have to guess.
return tx.backend.resources.FromLDAPWithType(rsrcID.Type(), result.Entries[0])
rsrc, err := tx.backend.resources.FromLDAP(result.Entries[0])
if err != nil {
return nil, err
}
return &as.RawResource{
Resource: *rsrc,
Owner: tx.backend.resources.GetOwner(rsrc),
}, nil
}
// CreateResource creates a new LDAP-backed resource object.
func (tx *backendTX) CreateResource(ctx context.Context, r *as.Resource) error {
dn, err := tx.backend.resources.GetDN(r.ID)
// Create a single resource, modify its ID in-place. Ignores previous ID value.
func (tx *backendTX) createSingleResource(user *as.User, rsrc *as.Resource) error {
dn, err := tx.backend.resources.MakeDN(user, rsrc)
if err != nil {
return err
}
// Here we assign resource IDs.
rsrc.ID = as.ResourceID(dn)
// Now write the resource data to LDAP.
tx.create(dn)
for _, attr := range tx.backend.resources.ToLDAP(r) {
for _, attr := range tx.backend.resources.ToLDAP(rsrc) {
tx.setAttr(dn, attr.Type, attr.Vals...)
}
return nil
}
// Sort the resource tree (defined by parent_id relations) breadth-first.
// Horrible algorithm: inefficient on large lists, never terminates on loops.
func sortResourcesByDepth(rsrcs []*as.Resource) []*as.Resource {
var out []*as.Resource
stack := []as.ResourceID{as.ResourceID("")}
for len(stack) > 0 {
cur := stack[0]
stack = stack[1:]
var left []*as.Resource
for _, r := range rsrcs {
if r.ParentID.Equal(cur) {
out = append(out, r)
stack = append(stack, r.ID)
} else {
left = append(left, r)
}
}
rsrcs = left
}
return out
}
// CreateResources creates new LDAP-backed resource objects. It
// modifies the input resource objects in-place and assigns them a
// ResourceID. Input resource IDs are ignored (they can be empty),
// with one exception: in order to resolve ParentIDs properly,
// resource IDs can be set to user-selected values and then referenced
// in another resource's ParentID. In this case, the resulting
// ParentID will be then set to the real resource ID of the parent
// resource.
func (tx *backendTX) CreateResources(ctx context.Context, user *as.User, rsrcs []*as.Resource) ([]*as.Resource, error) {
idMap := make(map[as.ResourceID]as.ResourceID)
for _, rsrc := range sortResourcesByDepth(rsrcs) {
oldID := rsrc.ID
if !rsrc.ParentID.Empty() {
pid, ok := idMap[rsrc.ParentID]
if !ok {
return nil, fmt.Errorf("resource %s references unknown parent_id %s", rsrc.String(), rsrc.ParentID.String())
}
rsrc.ParentID = pid
}
if err := tx.createSingleResource(user, rsrc); err != nil {
return nil, err
}
if !oldID.Empty() {
idMap[oldID] = rsrc.ID
}
}
return rsrcs, nil
}
// UpdateResource updates a LDAP-backed resource that was obtained by a previous GetResource call.
func (tx *backendTX) UpdateResource(ctx context.Context, r *as.Resource) error {
dn, err := tx.backend.resources.GetDN(r.ID)
......@@ -561,6 +621,43 @@ func (tx *backendTX) isUIDAvailable(ctx context.Context, uid int) (bool, error)
return true, nil
}
func (tx *backendTX) CanAccessResource(_ context.Context, username string, rsrc *as.Resource) bool {
switch rsrc.Type {
case as.ResourceTypeMailingList:
for _, a := range rsrc.List.Admins {
if a == username {
return true
}
}
return false
default:
dn, err := tx.backend.resources.GetDN(rsrc.ID)
if err != nil {
return false
}
owner := ownerFromDN(dn)
return owner == username
}
}
// Find the uid= in the DN. Return an empty string on failure.
func ownerFromDN(dn string) string {
parsed, err := ldap.ParseDN(dn)
if err != nil {
return ""
}
// Start looking for uid from the right. The strict
// greater-than clause ignores the first RDN (so that the
// owner of a user is not itself).
for i := len(parsed.RDNs) - 1; i > 0; i-- {
attr := parsed.RDNs[i].Attributes[0]
if attr.Type == "uid" {
return attr.Value
}
}
return ""
}
const oneDay = 86400
func encodeShadowTimestamp(t time.Time) string {
......
......@@ -2,6 +2,7 @@ package backend
import (
"context"
"fmt"
"testing"
"time"
......@@ -17,6 +18,7 @@ const (
testUser1 = "uno@investici.org"
testUser2 = "due@investici.org" // has encryption keys
testUser3 = "tre@investici.org" // has OTP
testBaseDN = "dc=example,dc=com"
)
func startServerAndGetUser(t testing.TB) (func(), as.Backend, *as.RawUser) {
......@@ -99,18 +101,9 @@ func TestModel_GetUser(t *testing.T) {