Commit e15acb1e authored by ale's avatar ale

Add unix user IDs to object types

Adds UIDs to users, websites, DAV accounts.

Assign a random UID to newly created users, and ensure that all
associated resources have the same UID as well.
parent 050a535b
Pipeline #1028 passed with stages
in 1 minute and 29 seconds
......@@ -695,10 +695,11 @@ type CreateResourcesResponse struct {
}
// ApplyTemplate fills in default values for the resources in the request.
func (req *CreateResourcesRequest) ApplyTemplate(ctx context.Context, s *AccountService, user *User) {
func (req *CreateResourcesRequest) ApplyTemplate(ctx context.Context, _ TX, s *AccountService, user *User) error {
for _, r := range req.Resources {
s.resourceTemplates.applyTemplate(ctx, r, user)
}
return nil
}
// ValidationUser returns the user to be used for validation purposes.
......@@ -782,17 +783,33 @@ type CreateUserRequest struct {
}
// ApplyTemplate fills in default values for the resources in the request.
func (req *CreateUserRequest) ApplyTemplate(ctx context.Context, s *AccountService, user *User) {
func (req *CreateUserRequest) ApplyTemplate(ctx context.Context, tx TX, s *AccountService, _ *User) error {
// Some fields should be unset because there are specific
// methods to modify those attributes.
req.User.Has2FA = false
req.User.HasEncryptionKeys = false
req.User.PasswordRecoveryHint = ""
req.User.AppSpecificPasswords = nil
if req.User.Lang == "" {
req.User.Lang = "en"
}
// Allocate a new user ID.
uid, err := tx.NextUID(ctx)
if err != nil {
return err
}
req.User.UID = uid
// Apply templates to all resources in the request.
for _, r := range req.User.Resources {
s.resourceTemplates.applyTemplate(ctx, r, user)
s.resourceTemplates.applyTemplate(ctx, r, req.User)
}
return nil
}
// Validate the request.
func (req *CreateUserRequest) Validate(ctx context.Context, s *AccountService, _ *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)
......@@ -814,20 +831,6 @@ type CreateUserResponse struct {
Password string `json:"password"`
}
// 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) {
var resp CreateUserResponse
......
......@@ -26,6 +26,10 @@ func (b *fakeBackend) Commit(_ context.Context) error {
return nil
}
func (b *fakeBackend) NextUID(_ context.Context) (int, error) {
return 42, nil
}
func (b *fakeBackend) GetUser(_ context.Context, username string) (*User, error) {
return b.users[username], nil
}
......@@ -176,6 +180,7 @@ func createFakeBackend() *fakeBackend {
}
fb.addUser(&User{
Name: "testuser",
UID: 4242,
Resources: []*Resource{
{
ID: NewResourceID(ResourceTypeEmail, "testuser", "testuser@example.com"),
......
......@@ -2,6 +2,9 @@ package backend
import (
"context"
"fmt"
"math/rand"
"strconv"
"strings"
ldaputil "git.autistici.org/ai3/go-common/ldap"
......@@ -22,6 +25,7 @@ const (
storagePrivateKeyLDAPAttr = "storageEncryptedSecretKey"
passwordLDAPAttr = "userPassword"
u2fRegistrationsLDAPAttr = "u2fRegistration"
uidNumberLDAPAttr = "uidNumber"
)
// backend is the interface to an LDAP-backed user database.
......@@ -36,8 +40,16 @@ type backend struct {
userQuery *queryTemplate
userResourceQueries []*queryTemplate
resources *resourceRegistry
// Range for new user IDs.
minUID, maxUID int
}
const (
defaultMinUID = 10000
defaultMaxUID = 1000000
)
// backendTX holds the business logic (that runs within a single
// transaction).
type backendTX struct {
......@@ -95,6 +107,8 @@ func newLDAPBackendWithConn(conn ldapConn, base string) (*backend, error) {
},
},
resources: rsrc,
minUID: defaultMinUID,
maxUID: defaultMaxUID,
}, nil
}
......@@ -107,9 +121,11 @@ func newUser(entry *ldap.Entry) (*accountserver.User, error) {
// The case of password recovery attributes is more complex:
// the current schema has those on email=, but we'd like to
// move them to uid=, so we currently have to support both.
uidNumber, _ := strconv.Atoi(entry.GetAttributeValue(uidNumberLDAPAttr))
user := &accountserver.User{
Name: entry.GetAttributeValue("uid"),
Lang: entry.GetAttributeValue(preferredLanguageLDAPAttr),
UID: uidNumber,
PasswordRecoveryHint: entry.GetAttributeValue(recoveryHintLDAPAttr),
U2FRegistrations: decodeU2FRegistrations(entry.GetAttributeValues(u2fRegistrationsLDAPAttr)),
}
......@@ -131,6 +147,7 @@ func userToLDAP(user *accountserver.User) (attrs []ldap.PartialAttribute) {
{Type: "objectClass", Vals: []string{"top", "person", "posixAccount", "shadowAccount", "organizationalPerson", "inetOrgPerson", "totpAccount"}},
{Type: "uid", Vals: s2l(user.Name)},
{Type: "cn", Vals: s2l(user.Name)},
{Type: uidNumberLDAPAttr, Vals: s2l(strconv.Itoa(user.UID))},
{Type: "givenName", Vals: []string{"Private"}},
{Type: "sn", Vals: []string{"Private"}},
{Type: "gecos", Vals: s2l(user.Name)},
......@@ -481,6 +498,47 @@ func (tx *backendTX) UpdateResource(ctx context.Context, r *accountserver.Resour
return nil
}
// NextUID returns an available user ID (uidNumber). It does so
// without keeping state by just guessing random UIDs in the
// minUID-maxUID range, and checking if they are available (so it's
// best to keep the range largely underutilized).
func (tx *backendTX) NextUID(ctx context.Context) (uid int, err error) {
// Won't actually run forever, at some point the context will expire.
var ok bool
for !ok && err == nil {
uid = tx.backend.minUID + rand.Intn(tx.backend.maxUID-tx.backend.minUID)
ok, err = tx.isUIDAvailable(ctx, uid)
}
return
}
func (tx *backendTX) isUIDAvailable(ctx context.Context, uid int) (bool, error) {
// Try to make this query lightweight: ask for no attributes,
// use a size limit of 1 and treat "Size Limit Exceeded"
// errors as a successful result.
result, err := tx.search(ctx, ldap.NewSearchRequest(
tx.backend.baseDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
1, // just one result is enough
0,
false,
fmt.Sprintf("(uidNumber=%d)", uid),
[]string{"dn"},
nil,
))
if err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultSizeLimitExceeded) {
return false, nil
}
return false, err
}
if len(result.Entries) > 0 {
return false, nil
}
return true, nil
}
var siteRoot = "/home/users/investici.org/"
// The hosting directory for a website is the path component immediately after
......
......@@ -320,3 +320,40 @@ func TestModel_SetUserEncryptionKeys_Replace(t *testing.T) {
t.Fatal("Commit", err)
}
}
func TestModel_NextUID(t *testing.T) {
stop, b, user := startServerAndGetUser(t)
defer stop()
tx, _ := b.NewTransaction()
// User UID should not be available.
ok, err := tx.(*backendTX).isUIDAvailable(context.Background(), user.UID)
if err != nil {
t.Fatal("isUIDAvailable", err)
}
if ok {
t.Fatalf("oops, the uid of the existing user (%d) appears to be available", user.UID)
}
// Check a UID that should be available instead.
freeUID := 4096
ok, err = tx.(*backendTX).isUIDAvailable(context.Background(), freeUID)
if err != nil {
t.Fatal("isUIDAvailable", err)
}
if !ok {
t.Fatalf("oops, a supposedly free uid (%d) appears to be unavailable", freeUID)
}
// Generate a new UID.
uid, err := tx.NextUID(context.Background())
if err != nil {
t.Fatal("NextUID", err)
}
if uid == 0 {
t.Fatal("uid is 0")
}
if uid == user.UID { // not that it's likely
t.Fatalf("uid is the same as the existing user ID (%d)", user.UID)
}
}
......@@ -62,6 +62,9 @@ type TX interface {
// necessary information.
GetUserEncryptedPassword(context.Context, *User) string
GetUserRecoveryEncryptedPassword(context.Context, *User) string
// Return the next (or any, really) available user ID.
NextUID(context.Context) (int, error)
}
// FindResourceRequest contains parameters for searching a resource by name.
......@@ -320,7 +323,7 @@ type hasNewContext interface {
}
type hasApplyTemplate interface {
ApplyTemplate(context.Context, *AccountService, *User)
ApplyTemplate(context.Context, TX, *AccountService, *User) error
}
type hasValidate interface {
......@@ -358,7 +361,9 @@ func (s *AccountService) withRequest(ctx context.Context, tx TX, req interface{}
// Apply a template to the request, to fill in default values
// etc., if the request has an ApplyTemplate() method.
if rt, ok := req.(hasApplyTemplate); ok {
rt.ApplyTemplate(ctx, s, user)
if err := rt.ApplyTemplate(ctx, tx, s, user); err != nil {
return err
}
}
// If the request has a Validate() method, validate the
......
......@@ -27,6 +27,9 @@ type User struct {
// Preferred language.
Lang string `json:"lang"`
// UNIX user id.
UID int `json:"uid"`
Has2FA bool `json:"has_2fa"`
HasEncryptionKeys bool `json:"has_encryption_keys"`
PasswordRecoveryHint string `json:"password_recovery_hint"`
......@@ -303,12 +306,14 @@ type MailingList struct {
// WebDAV represents a hosting account.
type WebDAV struct {
UID int `json:"uid"`
Homedir string `json:"homedir"`
}
// Website resource attributes.
type Website struct {
URL string `json:"url,omitempty"`
UID int `json:"uid"`
ParentDomain string `json:"parent_domain,omitempty"`
AcceptMail bool `json:"accept_mail"`
Options []string `json:"options,omitempty"`
......
......@@ -38,6 +38,8 @@ type validationContext struct {
forbiddenPasswords stringSet
minPasswordLength int
maxPasswordLength int
minUID int
maxUID int
webroot string
domains domainBackend
shards shardBackend
......@@ -479,6 +481,37 @@ func hasMatchingDAVAccount(user *User, r *Resource) bool {
return findMatchingDAVAccount(user, r) != nil
}
func (v *validationContext) validateUID(uid int, user *User) error {
if uid == 0 {
return errors.New("uid is not set")
}
if uid < v.minUID || (v.maxUID > 0 && uid > v.maxUID) {
return errors.New("uid outside of allowed range")
}
if user != nil && uid != user.UID {
return errors.New("uid of resource differs from uid of user")
}
return nil
}
func (v *validationContext) validateWebsiteCommon(r *Resource, user *User) error {
// Document root checks: must not be empty, and the
// user must have a DAV account with a home directory
// that is a parent of this document root.
if r.Website.DocumentRoot == "" {
return errors.New("empty document_root")
}
if !isSubdir(v.webroot, r.Website.DocumentRoot) {
return errors.New("document root outside of web root")
}
if !hasMatchingDAVAccount(user, r) {
return errors.New("website has no matching DAV account")
}
// UID checks.
return v.validateUID(r.Website.UID, user)
}
func (v *validationContext) validDomainResource() ResourceValidatorFunc {
domainValidator := allOf(
minLength(6),
......@@ -506,17 +539,7 @@ func (v *validationContext) validDomainResource() ResourceValidatorFunc {
return errors.New("non-empty parent_domain on domain resource")
}
// Document root checks.
if r.Website.DocumentRoot == "" {
return errors.New("empty document_root")
}
if !isSubdir(v.webroot, r.Website.DocumentRoot) {
return errors.New("document root outside of web root")
}
if !hasMatchingDAVAccount(user, r) {
return errors.New("website has no matching DAV account")
}
return nil
return v.validateWebsiteCommon(r, user)
}
}
......@@ -548,19 +571,7 @@ func (v *validationContext) validWebsiteResource() ResourceValidatorFunc {
return err
}
// Document root checks: must not be empty, and the
// user must have a DAV account with a home directory
// that is a parent of this document root.
if r.Website.DocumentRoot == "" {
return errors.New("empty document_root")
}
if !isSubdir(v.webroot, r.Website.DocumentRoot) {
return errors.New("document root outside of web root")
}
if !hasMatchingDAVAccount(user, r) {
return errors.New("website has no matching DAV account")
}
return nil
return v.validateWebsiteCommon(r, user)
}
}
......@@ -581,7 +592,8 @@ func (v *validationContext) validDAVResource() ResourceValidatorFunc {
if !isSubdir(v.webroot, r.DAV.Homedir) {
return errors.New("homedir outside of web root")
}
return nil
return v.validateUID(r.DAV.UID, user)
}
}
......@@ -726,6 +738,8 @@ func (c *templateContext) websiteResourceTemplate(ctx context.Context, r *Resour
r.Website.Options = []string{"nomail"}
}
r.Website.UID = user.UID
dav := findMatchingDAVAccount(user, r)
c.setResourceShard(ctx, r, dav)
c.setResourceStatus(r)
......@@ -741,6 +755,7 @@ func (c *templateContext) davResourceTemplate(ctx context.Context, r *Resource,
r.DAV.Homedir = filepath.Join(c.webroot, r.ID.Name())
}
r.DAV.Homedir = filepath.Clean(r.DAV.Homedir)
r.DAV.UID = user.UID
c.setResourceShard(ctx, r, nil)
c.setResourceStatus(r)
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment