diff --git a/docker/kolab/kolab-init.sh b/docker/kolab/kolab-init.sh index 684a2c7c..f93ceceb 100755 --- a/docker/kolab/kolab-init.sh +++ b/docker/kolab/kolab-init.sh @@ -1,30 +1,31 @@ #!/bin/bash if [ -d "/etc/dirsrv/slapd-kolab/" ]; then exit 0 fi pushd /root/utils/ ./01-reverse-etc-hosts.sh ./02-write-my.cnf.sh ./03-setup-kolab.sh ./04-reset-mysql-kolab-password.sh ./05-replace-localhost.sh ./06-mysql-for-kolabdev.sh ./07-adjust-base-dns.sh ./08-disable-amavisd.sh ./09-enable-debugging.sh ./10-reset-kolab-service-password.sh ./11-reset-cyrus-admin-password.sh ./12-create-hosted-kolab-service.sh ./13-create-ou-domains.sh ./14-create-management-domain.sh ./15-create-hosted-domain.sh ./16-remove-cn-kolab-cn-config.sh ./17-remove-hosted-service-access-from-mgmt-domain.sh ./18-adjust-kolab-conf.sh ./19-turn-on-vlv-in-roundcube.sh ./20-add-alias-attribute-index.sh +./21-adjust-postfix-config.sh touch /tmp/kolab-init.done diff --git a/docker/kolab/utils/15-create-hosted-domain.sh b/docker/kolab/utils/15-create-hosted-domain.sh index e98e66c0..6305ca82 100755 --- a/docker/kolab/utils/15-create-hosted-domain.sh +++ b/docker/kolab/utils/15-create-hosted-domain.sh @@ -1,87 +1,99 @@ #!/bin/bash . ./settings.sh ( echo "dn: associateddomain=${hosted_domain},ou=Domains,${rootdn}" echo "objectclass: top" echo "objectclass: domainrelatedobject" echo "objectclass: inetdomain" echo "inetdomainstatus: active" echo "associateddomain: ${hosted_domain}" echo "inetdomainbasedn: ${hosted_domain_rootdn}" echo "" echo "dn: cn=$(echo ${hosted_domain_rootdn} | sed -e 's/=/\\3D/g' -e 's/,/\\2D/g'),cn=mapping tree,cn=config" echo "objectClass: top" echo "objectClass: extensibleObject" echo "objectClass: nsMappingTree" echo "nsslapd-state: backend" echo "cn: ${hosted_domain_rootdn}" echo "nsslapd-backend: $(echo ${hosted_domain} | sed -e 's/\./_/g')" echo "" echo "dn: cn=$(echo ${hosted_domain} | sed -e 's/\./_/g'),cn=ldbm database,cn=plugins,cn=config" echo "objectClass: top" echo "objectClass: extensibleobject" echo "objectClass: nsbackendinstance" echo "cn: $(echo ${hosted_domain} | sed -e 's/\./_/g')" echo "nsslapd-suffix: ${hosted_domain_rootdn}" echo "nsslapd-cachesize: -1" echo "nsslapd-cachememsize: 10485760" echo "nsslapd-readonly: off" echo "nsslapd-require-index: off" echo "nsslapd-directory: /var/lib/dirsrv/slapd-$(hostname -s)/db/$(echo ${hosted_domain} | sed -e 's/\./_/g')" echo "nsslapd-dncachememsize: 10485760" echo "" ) | ldapadd -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}" ( echo "dn: ${hosted_domain_rootdn}" echo "aci: (targetattr=\"carLicense || description || displayName || facsimileTelephoneNumber || homePhone || homePostalAddress || initials || jpegPhoto || labeledURI || mobile || pager || photo || postOfficeBox || postalAddress || postalCode || preferredDeliveryMethod || preferredLanguage || registeredAddress || roomNumber || secretary || seeAlso || st || street || telephoneNumber || telexNumber || title || userCertificate || userPassword || userSMIMECertificate || x500UniqueIdentifier\")(version 3.0; acl \"Enable self write for common attributes\"; allow (write) userdn=\"ldap:///self\";)" echo "aci: (targetattr =\"*\")(version 3.0;acl \"Directory Administrators Group\";allow (all) (groupdn=\"ldap:///cn=Directory Administrators,${hosted_domain_rootdn}\" or roledn=\"ldap:///cn=kolab-admin,${hosted_domain_rootdn}\");)" echo "aci: (targetattr=\"*\")(version 3.0; acl \"Configuration Administrators Group\"; allow (all) groupdn=\"ldap:///cn=Configuration Administrators,ou=Groups,ou=TopologyManagement,o=NetscapeRoot\";)" echo "aci: (targetattr=\"*\")(version 3.0; acl \"Configuration Administrator\"; allow (all) userdn=\"ldap:///uid=admin,ou=Administrators,ou=TopologyManagement,o=NetscapeRoot\";)" echo "aci: (targetattr = \"*\")(version 3.0; acl \"SIE Group\"; allow (all) groupdn = \"ldap:///cn=slapd-$(hostname -s),cn=389 Directory Server,cn=Server Group,cn=$(hostname -f),ou=${domain},o=NetscapeRoot\";)" echo "aci: (targetattr = \"*\") (version 3.0;acl \"Search Access\";allow (read,compare,search)(userdn = \"ldap:///${hosted_domain_rootdn}??sub?(objectclass=*)\");)" echo "aci: (targetattr = \"*\") (version 3.0;acl \"Service Search Access\";allow (read,compare,search)(userdn = \"ldap:///uid=kolab-service,ou=Special Users,${rootdn}\");)" echo "objectClass: top" echo "objectClass: domain" echo "dc: $(echo ${hosted_domain} | cut -d'.' -f 1)" echo "" ) | ldapadd -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}" ( + for role in "2fa-user" "activesync-user" "imap-user"; do + echo "cn=${role},${hosted_domain_rootdn}" + echo "cn: ${role}" + echo "description: ${role} role" + echo "objectclass: top" + echo "objectclass: ldapsubentry" + echo "objectclass: nsmanagedroledefinition" + echo "objectclass: nsroledefinition" + echo "objectclass: nssimpleroledefinition" + echo "" + done + echo "dn: ou=Groups,${hosted_domain_rootdn}" echo "ou: Groups" echo "objectClass: top" echo "objectClass: organizationalunit" echo "" echo "dn: ou=People,${hosted_domain_rootdn}" echo "aci: (targetattr = \"*\") (version 3.0;acl \"Hosted Kolab Services\";allow (all)(userdn = \"ldap:///uid=hosted-kolab-service,ou=Special Users,${rootdn}\");)" echo "ou: People" echo "objectClass: top" echo "objectClass: organizationalunit" echo "" echo "dn: ou=Special Users,${hosted_domain_rootdn}" echo "ou: Special Users" echo "objectClass: top" echo "objectClass: organizationalunit" echo "" echo "dn: ou=Resources,${hosted_domain_rootdn}" echo "ou: Resources" echo "objectClass: top" echo "objectClass: organizationalunit" echo "" echo "dn: ou=Shared Folders,${hosted_domain_rootdn}" echo "ou: Shared Folders" echo "objectClass: top" echo "objectClass: organizationalunit" echo "" ) | ldapadd -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}" diff --git a/docker/kolab/utils/21-adjust-postfix-config.sh b/docker/kolab/utils/21-adjust-postfix-config.sh new file mode 100755 index 00000000..0f5a91ed --- /dev/null +++ b/docker/kolab/utils/21-adjust-postfix-config.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# new: (inetdomainstatus:1.2.840.113556.1.4.803:=1) +# active: (inetdomainstatus:1.2.840.113556.1.4.803:=2) +# suspended: (inetdomainstatus:1.2.840.113556.1.4.803:=4) +# deleted: (inetdomainstatus:1.2.840.113556.1.4.803:=8) +# confirmed: (inetdomainstatus:1.2.840.113556.1.4.803:=16) +# verified: (inetdomainstatus:1.2.840.113556.1.4.803:=32) +# ready: (inetdomainstatus:1.2.840.113556.1.4.803:=64) + +sed -i -r \ + -e 's/^query_filter.*$/query_filter = (\&(associatedDomain=%s)(inetdomainstatus:1.2.840.113556.1.4.803:=18)(!(inetdomainstatus:1.2.840.113556.1.4.803:=4)))/g' \ + /etc/postfix/ldap/mydestination.cf + +# new: (inetuserstatus:1.2.840.113556.1.4.803:=1) +# active: (inetuserstatus:1.2.840.113556.1.4.803:=2) +# suspended: (inetuserstatus:1.2.840.113556.1.4.803:=4) +# deleted: (inetuserstatus:1.2.840.113556.1.4.803:=8) +# ldapready: (inetuserstatus:1.2.840.113556.1.4.803:=16) +# imapready: (inetuserstatus:1.2.840.113556.1.4.803:=32) + +sed -i -r \ + -e 's/^query_filter.*$/query_filter = (\&(|(mail=%s)(alias=%s))(|(objectclass=kolabinetorgperson)(|(objectclass=kolabgroupofuniquenames)(objectclass=kolabgroupofurls))(|(|(objectclass=groupofuniquenames)(objectclass=groupofurls))(objectclass=kolabsharedfolder))(objectclass=kolabsharedfolder))(inetuserstatus:1.2.840.113556.1.4.803:=50)(!(inetuserstatus:1.2.840.113556.1.4.803:=4)))/g' \ + /etc/postfix/ldap/local_recipient_maps.cf + +systemctl restart postfix diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php index 959eca90..d33562cf 100644 --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1,413 +1,511 @@ namespace},{$hosted_root_dn}"; + $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; $aci = [ '(targetattr = "*")' . '(version 3.0; acl "Deny Unauthorized"; deny (all)' - . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmt_root_dn - . ' || ldap:///ou=People,' . $domain_base_dn . '??sub?(objectclass=inetorgperson)") ' - . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmt_root_dn . '";)', + . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN + . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' + . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search)' - . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmt_root_dn - . ' || ldap:///ou=People,' . $domain_base_dn . '??sub?(objectclass=inetorgperson)");)', + . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN + . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' - . '(roledn = "ldap:///cn=kolab-admin,' . $domain_base_dn - . ' || ldap:///cn=kolab-admin,' . $mgmt_root_dn . '");)' + . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN + . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)' ]; $entry = [ 'aci' => $aci, 'associateddomain' => $domain->namespace, - 'inetdomainbasedn' => $domain_base_dn, + 'inetdomainbasedn' => $domainBaseDN, 'objectclass' => [ 'top', 'domainrelatedobject', 'inetdomain' ], ]; $dn = "associateddomain={$domain->namespace},{$config['domain_base_dn']}"; if (!$ldap->get_entry($dn)) { $ldap->add_entry($dn, $entry); } // create ou, roles, ous $entry = [ 'description' => $domain->namespace, 'objectclass' => [ 'top', 'organizationalunit' ], 'ou' => $domain->namespace, ]; $entry['aci'] = array( '(targetattr = "*")' . '(version 3.0;acl "Deny Unauthorized"; deny (all)' - . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmt_root_dn - . ' || ldap:///ou=People,' . $domain_base_dn . '??sub?(objectclass=inetorgperson)") ' - . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmt_root_dn . '";)', + . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN + . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' + . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search,write)' - . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmt_root_dn - . ' || ldap:///ou=People,' . $domain_base_dn . '??sub?(objectclass=inetorgperson)");)', + . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN + . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' - . '(roledn = "ldap:///cn=kolab-admin,' . $domain_base_dn - . ' || ldap:///cn=kolab-admin,' . $mgmt_root_dn . '");)', + . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN + . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)', - '(target = "ldap:///ou=*,' . $domain_base_dn . '")' + '(target = "ldap:///ou=*,' . $domainBaseDN . '")' . '(targetattr="objectclass || aci || ou")' . '(version 3.0;acl "Allow Domain sub-OU Registration"; allow (add)' - . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmt_root_dn . '");)', + . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', - '(target = "ldap:///uid=*,ou=People,' . $domain_base_dn . '")(targetattr="*")' + '(target = "ldap:///uid=*,ou=People,' . $domainBaseDN . '")(targetattr="*")' . '(version 3.0;acl "Allow Domain First User Registration"; allow (add)' - . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmt_root_dn . '");)', + . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', - '(target = "ldap:///cn=*,' . $domain_base_dn . '")(targetattr="objectclass || cn")' + '(target = "ldap:///cn=*,' . $domainBaseDN . '")(targetattr="objectclass || cn")' . '(version 3.0;acl "Allow Domain Role Registration"; allow (add)' - . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmt_root_dn . '");)', + . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', ); - if (!$ldap->get_entry($domain_base_dn)) { - $ldap->add_entry($domain_base_dn, $entry); + if (!$ldap->get_entry($domainBaseDN)) { + $ldap->add_entry($domainBaseDN, $entry); } foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) { - if (!$ldap->get_entry("ou={$item},{$domain_base_dn}")) { + if (!$ldap->get_entry("ou={$item},{$domainBaseDN}")) { $ldap->add_entry( - "ou={$item},{$domain_base_dn}", + "ou={$item},{$domainBaseDN}", [ 'ou' => $item, 'description' => $item, 'objectclass' => [ 'top', 'organizationalunit' ] ] ); } } - foreach (['kolab-admin', 'imap-user', 'activesync-user', 'billing-user'] as $item) { - if (!$ldap->get_entry("cn={$item},{$domain_base_dn}")) { + foreach (['kolab-admin', 'billing-user'] as $item) { + if (!$ldap->get_entry("cn={$item},{$domainBaseDN}")) { $ldap->add_entry( - "cn={$item},{$domain_base_dn}", + "cn={$item},{$domainBaseDN}", [ 'cn' => $item, 'description' => "{$item} role", 'objectclass' => [ 'top', 'ldapsubentry', 'nsmanagedroledefinition', 'nsroledefinition', 'nssimpleroledefinition' ] ] ); } } $ldap->close(); } /** * Create a user in LDAP. * * Only need to add user if in any of the local domains? Figure that out here for now. Should * have Context-Based Access Controls before the job is queued though, probably. * * Use one of three modes; * * 1) The authenticated user account. * * * Only valid if the authenticated user is a domain admin. * * We don't know the originating user here. * * We certainly don't have its password anymore. * * 2) The hosted kolab account. * * 3) The Directory Manager account. * * @param \App\User $user The user account to create. * * @return bool|void */ public static function createUser(User $user) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); list($_local, $_domain) = explode('@', $user->email, 2); $domain = $ldap->find_domain($_domain); if (!$domain) { return false; } $entry = [ 'objectclass' => [ 'top', 'inetorgperson', + 'inetuser', 'kolabinetorgperson', 'mailrecipient', 'person' ], 'mail' => $user->email, 'uid' => $user->email, + 'nsroledn' => [] ]; self::setUserAttributes($user, $entry); $base_dn = $ldap->domain_root_dn($_domain); $dn = "uid={$user->email},ou=People,{$base_dn}"; if (!$ldap->get_entry($dn)) { $ldap->add_entry($dn, $entry); } $ldap->close(); } /** * Update a domain in LDAP. * * @param \App\Domain $domain The domain to update. * * @return void */ public static function updateDomain($domain) { - // + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $ldapDomain = $ldap->find_domain($domain->namespace); + + $oldEntry = $ldap->get_entry($ldapDomain['dn']); + $newEntry = $oldEntry; + + self::setDomainAttributes($domain, $newEntry); + + $ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry); + + $ldap->close(); } public static function deleteDomain($domain) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - $hosted_root_dn = \config('ldap.hosted.root_dn'); - $mgmt_root_dn = \config('ldap.admin.root_dn'); + $hostedRootDN = \config('ldap.hosted.root_dn'); + $mgmtRootDN = \config('ldap.admin.root_dn'); - $domain_base_dn = "ou={$domain->namespace},{$hosted_root_dn}"; + $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; - if ($ldap->get_entry($domain_base_dn)) { - $ldap->delete_entry_recursive($domain_base_dn); + if ($ldap->get_entry($domainBaseDN)) { + $ldap->delete_entry_recursive($domainBaseDN); } if ($ldap_domain = $ldap->find_domain($domain->namespace)) { if ($ldap->get_entry($ldap_domain['dn'])) { $ldap->delete_entry($ldap_domain['dn']); } } + + $ldap->close(); } public static function deleteUser($user) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); list($_local, $_domain) = explode('@', $user->email, 2); $domain = $ldap->find_domain($_domain); if (!$domain) { + $ldap->close(); return false; } $base_dn = $ldap->domain_root_dn($_domain); $dn = "uid={$user->email},ou=People,{$base_dn}"; if (!$ldap->get_entry($dn)) { + $ldap->close(); return false; } $ldap->delete_entry($dn); + + $ldap->close(); } /** * Update a user in LDAP. * * @param \App\User $user The user account to update. * * @return bool|void */ public static function updateUser(User $user) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); list($_local, $_domain) = explode('@', $user->email, 2); $domain = $ldap->find_domain($_domain); if (!$domain) { + $ldap->close(); return false; } $base_dn = $ldap->domain_root_dn($_domain); $dn = "uid={$user->email},ou=People,{$base_dn}"; - $old_entry = $ldap->get_entry($dn); - $new_entry = $old_entry; + $oldEntry = $ldap->get_entry($dn); - self::setUserAttributes($user, $new_entry); + if (!$oldEntry) { + $ldap->close(); + return false; + } + + if (!array_key_exists('nsroledn', $oldEntry)) { + $oldEntry['nsroledn'] = (array)$ldap->get_entry_attributes($dn, ['nsroledn']); + } + + $newEntry = $oldEntry; + + self::setUserAttributes($user, $newEntry); - $ldap->modify_entry($dn, $old_entry, $new_entry); + $ldap->modify_entry($dn, $oldEntry, $newEntry); $ldap->close(); } /** * Initialize connection to LDAP */ private static function initLDAP(array $config, string $privilege = 'admin') { $ldap = new \Net_LDAP3($config); $ldap->connect(); $ldap->bind(\config("ldap.{$privilege}.bind_dn"), \config("ldap.{$privilege}.bind_pw")); // TODO: error handling return $ldap; } + /** + * Set domain attributes + */ + private static function setDomainAttributes(Domain $domain, array &$entry) + { + $entry['inetdomainstatus'] = $domain->status; + } + /** * Set common user attributes */ private static function setUserAttributes(User $user, array &$entry) { $firstName = $user->getSetting('first_name'); $lastName = $user->getSetting('last_name'); $cn = "unknown"; $displayname = ""; if ($firstName) { if ($lastName) { $cn = "{$firstName} {$lastName}"; $displayname = "{$lastName}, {$firstName}"; } else { $lastName = "unknown"; $cn = "{$firstName}"; $displayname = "{$firstName}"; } } else { $firstName = ""; if ($lastName) { $cn = "{$lastName}"; $displayname = "{$lastName}"; } else { $lastName = "unknown"; } } $entry['cn'] = $cn; $entry['displayname'] = $displayname; $entry['givenname'] = $firstName; $entry['sn'] = $lastName; $entry['userpassword'] = $user->password_ldap; + + $entry['inetuserstatus'] = $user->status; + + $entry['mailquota'] = 0; + + if (!array_key_exists('nsroledn', $entry)) { + $entry['nsroledn'] = []; + } else if (!is_array($entry['nsroledn'])) { + $entry['nsroledn'] = (array)$entry['nsroledn']; + } + + $roles = []; + + foreach ($user->entitlements as $entitlement) { + \Log::debug("Examining {$entitlement->sku->title}"); + + switch ($entitlement->sku->title) { + case "storage": + $entry['mailquota'] += 1048576; + break; + } + + $roles[] = $entitlement->sku->title; + } + + $hostedRootDN = \config('ldap.hosted.root_dn'); + + if (in_array("2fa", $roles)) { + $entry['nsroledn'][] = "cn=2fa-user,{$hostedRootDN}"; + } else { + $key = array_search("cn=2fa-user,{$hostedRootDN}", $entry['nsroledn']); + if ($key !== false) { + unset($entry['nsroledn'][$key]); + } + } + + if (in_array("activesync", $roles)) { + $entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}"; + } else { + $key = array_search("cn=activesync-user,{$hostedRootDN}", $entry['nsroledn']); + if ($key !== false) { + unset($entry['nsroledn'][$key]); + } + } + + if (!in_array("groupware", $roles)) { + $entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}"; + } else { + $key = array_search("cn=imap-user,{$hostedRootDN}", $entry['nsroledn']); + if ($key !== false) { + unset($entry['nsroledn'][$key]); + } + } + + $entry['nsroledn'] = array_unique($entry['nsroledn']); } /** * Get LDAP configuration for specified access level */ private static function getConfig(string $privilege) { $config = [ 'domain_base_dn' => \config('ldap.domain_base_dn'), 'domain_filter' => \config('ldap.domain_filter'), 'domain_name_attribute' => \config('ldap.domain_name_attribute'), 'hosts' => \config('ldap.hosts'), 'sort' => false, 'vlv' => false, 'log_hook' => 'App\Backends\LDAP::logHook', ]; return $config; } /** * Logging callback */ public static function logHook($level, $msg): void { if ( - ($level == LOG_INFO || $level == LOG_DEBUG || $level == LOG_NOTICE) + ( + $level == LOG_INFO + || $level == LOG_DEBUG + || $level == LOG_NOTICE + ) && !\config('app.debug') ) { return; } switch ($level) { case LOG_CRIT: $function = 'critical'; break; case LOG_EMERG: $function = 'emergency'; break; case LOG_ERR: $function = 'error'; break; case LOG_ALERT: $function = 'alert'; break; case LOG_WARNING: $function = 'warning'; break; case LOG_INFO: $function = 'info'; break; case LOG_DEBUG: $function = 'debug'; break; case LOG_NOTICE: $function = 'notice'; break; default: $function = 'info'; } if (is_array($msg)) { $msg = implode("\n", $msg); } $msg = '[LDAP] ' . $msg; \Log::{$function}($msg); } } diff --git a/src/app/Console/Commands/DomainList.php b/src/app/Console/Commands/DomainList.php new file mode 100644 index 00000000..03b56a79 --- /dev/null +++ b/src/app/Console/Commands/DomainList.php @@ -0,0 +1,47 @@ +orderBy('namespace')->each( + function ($domain) { + $this->info($domain->namespace); + } + ); + } +} diff --git a/src/app/Console/Commands/DomainStatus.php b/src/app/Console/Commands/DomainStatus.php new file mode 100644 index 00000000..eba31be3 --- /dev/null +++ b/src/app/Console/Commands/DomainStatus.php @@ -0,0 +1,51 @@ +argument('domain'))->first(); + + if (!$domain) { + return 1; + } + + $this->info("Found domain: {$domain->id}"); + + $this->info($domain->status); + } +} diff --git a/src/app/Console/Commands/DomainSuspend.php b/src/app/Console/Commands/DomainSuspend.php new file mode 100644 index 00000000..681dd20e --- /dev/null +++ b/src/app/Console/Commands/DomainSuspend.php @@ -0,0 +1,51 @@ +argument('domain'))->first(); + + if (!$domain) { + return 1; + } + + $this->info("Found domain: {$domain->id}"); + + $domain->suspend(); + } +} diff --git a/src/app/Console/Commands/DomainUnsuspend.php b/src/app/Console/Commands/DomainUnsuspend.php new file mode 100644 index 00000000..ba92a818 --- /dev/null +++ b/src/app/Console/Commands/DomainUnsuspend.php @@ -0,0 +1,51 @@ +argument('domain'))->first(); + + if (!$domain) { + return 1; + } + + $this->info("Found domain {$domain->id}"); + + $domain->unsuspend(); + } +} diff --git a/src/app/Console/Commands/UserStatus.php b/src/app/Console/Commands/UserStatus.php new file mode 100644 index 00000000..10eff915 --- /dev/null +++ b/src/app/Console/Commands/UserStatus.php @@ -0,0 +1,51 @@ +argument('user'))->first(); + + if (!$user) { + return 1; + } + + $this->info("Found user: {$user->id}"); + + $this->info($user->status); + } +} diff --git a/src/app/Console/Commands/UserSuspend.php b/src/app/Console/Commands/UserSuspend.php new file mode 100644 index 00000000..d0f90ede --- /dev/null +++ b/src/app/Console/Commands/UserSuspend.php @@ -0,0 +1,51 @@ +argument('user'))->first(); + + if (!$user) { + return 1; + } + + $this->info("Found user: {$user->id}"); + + $user->suspend(); + } +} diff --git a/src/app/Console/Commands/UserUnsuspend.php b/src/app/Console/Commands/UserUnsuspend.php new file mode 100644 index 00000000..8497de6a --- /dev/null +++ b/src/app/Console/Commands/UserUnsuspend.php @@ -0,0 +1,51 @@ +argument('user'))->first(); + + if (!$user) { + return 1; + } + + $this->info("Found user {$user->id}"); + + $user->unsuspend(); + } +} diff --git a/src/app/Console/Commands/UserVerify.php b/src/app/Console/Commands/UserVerify.php new file mode 100644 index 00000000..d37da94a --- /dev/null +++ b/src/app/Console/Commands/UserVerify.php @@ -0,0 +1,51 @@ +argument('user'))->first(); + + if (!$user) { + return 1; + } + + $this->info("Found user: {$user->id}"); + + $job = new \App\Jobs\UserVerify($user); + $job->handle(); + } +} diff --git a/src/app/Domain.php b/src/app/Domain.php index 7978eccf..dd78b850 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,348 +1,378 @@ wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'entitleable_id' => $this->id, 'entitleable_type' => Domain::class ] ); } } return $this; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Return list of public+active domain names */ public static function getPublicDomains(): array { $where = sprintf('(type & %s)', Domain::TYPE_PUBLIC); return self::whereRaw($where)->get(['namespace'])->pluck('namespace')->toArray(); } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is confirmed the ownership of. * * @return bool */ public function isConfirmed(): bool { return ($this->status & self::STATUS_CONFIRMED) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this domain is registered with us. * * @return bool */ public function isExternal(): bool { return ($this->type & self::TYPE_EXTERNAL) > 0; } /** * Returns whether this domain is hosted with us. * * @return bool */ public function isHosted(): bool { return ($this->type & self::TYPE_HOSTED) > 0; } /** * Returns whether this domain is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is public. * * @return bool */ public function isPublic(): bool { return ($this->type & self::TYPE_PUBLIC) > 0; } /** * Returns whether this domain is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isVerified(): bool { return ($this->status & self::STATUS_VERIFIED) > 0; } /** * Domain status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_CONFIRMED, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_VERIFIED, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid domain status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Ownership verification by checking for a TXT (or CNAME) record * in the domain's DNS (that matches the verification hash). * * @return bool True if verification was successful, false otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function confirm(): bool { if ($this->isConfirmed()) { return true; } $hash = $this->hash(self::HASH_TEXT); $confirmed = false; // Get DNS records and find a matching TXT entry $records = \dns_get_record($this->namespace, DNS_TXT); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $record) { if ($record['txt'] === $hash) { $confirmed = true; break; } } // Get DNS records and find a matching CNAME entry // Note: some servers resolve every non-existing name // so we need to define left and right side of the CNAME record // i.e.: kolab-verify IN CNAME .domain.tld. if (!$confirmed) { $cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace; $records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $records) { if ($records['target'] === $cname) { $confirmed = true; break; } } } if ($confirmed) { $this->status |= Domain::STATUS_CONFIRMED; $this->save(); } return $confirmed; } /** * Generate a verification hash for this domain * * @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT * * @return string Verification hash */ public function hash($mod = null): string { $cname = 'kolab-verify'; if ($mod === self::HASH_CNAME) { return $cname; } $hash = \md5('hkccp-verify-' . $this->namespace); return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash; } + /** + * Suspend this domain. + * + * @return void + */ + public function suspend(): void + { + if ($this->isSuspended()) { + return; + } + + $this->status |= Domain::STATUS_SUSPENDED; + $this->save(); + } + + /** + * Unsuspend this domain. + * + * @return void + */ + public function unsuspend(): void + { + if (!$this->isSuspended()) { + return; + } + + $this->status ^= Domain::STATUS_SUSPENDED; + $this->save(); + } + /** * Verify if a domain exists in DNS * * @return bool True if registered, False otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function verify(): bool { if ($this->isVerified()) { return true; } $record = \dns_get_record($this->namespace, DNS_ANY); if ($record === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } if (!empty($record)) { $this->status |= Domain::STATUS_VERIFIED; $this->save(); return true; } return false; } /** * Returns the wallet by which the domain is controlled * * @return \App\Wallet A wallet object */ public function wallet(): Wallet { return $this->entitlement()->first()->wallet; } } diff --git a/src/app/Jobs/DomainUpdate.php b/src/app/Jobs/DomainUpdate.php new file mode 100644 index 00000000..59df0914 --- /dev/null +++ b/src/app/Jobs/DomainUpdate.php @@ -0,0 +1,44 @@ +domain_id = $domain_id; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $domain = \App\Domain::find($this->domain_id); + + LDAP::updateDomain($domain); + } +} diff --git a/src/app/Observers/DomainObserver.php b/src/app/Observers/DomainObserver.php index 29c54ec0..af9cc23c 100644 --- a/src/app/Observers/DomainObserver.php +++ b/src/app/Observers/DomainObserver.php @@ -1,107 +1,106 @@ {$domain->getKeyName()} = $allegedly_unique; break; } } $domain->status |= Domain::STATUS_NEW | Domain::STATUS_ACTIVE; } /** * Handle the domain "created" event. * * @param \App\Domain $domain The domain. * * @return void */ public function created(Domain $domain) { // Create domain record in LDAP, then check if it exists in DNS \App\Jobs\DomainCreate::dispatch($domain); } /** * Handle the domain "deleting" event. * * @param \App\Domain $domain The domain. * * @return void */ public function deleting(Domain $domain) { // Entitlements do not have referential integrity on the entitled object, so this is our // way of doing an onDelete('cascade') without the foreign key. \App\Entitlement::where('entitleable_id', $domain->id) ->where('entitleable_type', Domain::class) ->delete(); } /** * Handle the domain "deleted" event. * * @param \App\Domain $domain The domain. * * @return void */ public function deleted(Domain $domain) { \App\Jobs\DomainDelete::dispatch($domain->id); } /** * Handle the domain "updated" event. * * @param \App\Domain $domain The domain. * * @return void */ public function updated(Domain $domain) { - // + \App\Jobs\DomainUpdate::dispatch($domain->id); } - /** * Handle the domain "restored" event. * * @param \App\Domain $domain The domain. * * @return void */ public function restored(Domain $domain) { // } /** * Handle the domain "force deleted" event. * * @param \App\Domain $domain The domain. * * @return void */ public function forceDeleted(Domain $domain) { // } } diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php index 54291a02..c5c0d3af 100644 --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -1,73 +1,82 @@ {$entitlement->getKeyName()} = $allegedly_unique; break; } } // can't dispatch job here because it'll fail serialization // Make sure the owner is at least a controller on the wallet $wallet = \App\Wallet::find($entitlement->wallet_id); if (!$wallet || !$wallet->owner) { return false; } $sku = \App\Sku::find($entitlement->sku_id); if (!$sku) { return false; } $result = $sku->handler_class::preReq($entitlement, $wallet->owner); if (!$result) { return false; } } + public function created(Entitlement $entitlement) + { + $entitlement->entitleable->updated_at = Carbon::now(); + $entitlement->entitleable->save(); + } + /** * Handle the entitlement "deleted" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleted(Entitlement $entitlement) { // Remove all configured 2FA methods from Roundcube database if ($entitlement->sku->title == '2fa') { // FIXME: Should that be an async job? $sf = new \App\Auth\SecondFactor($entitlement->entitleable); $sf->removeFactors(); } + + $entitlement->entitleable->updated_at = Carbon::now(); + $entitlement->entitleable->save(); } } diff --git a/src/app/User.php b/src/app/User.php index c8b0d643..62fdef93 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,575 +1,605 @@ 'datetime', ]; /** * Any wallets on which this user is a controller. * * This does not include wallets owned by the user. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function accounts() { return $this->belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Email aliases of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function aliases() { return $this->hasMany('App\UserAlias', 'user_id'); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } $wallet_id = $this->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] ); } } return $user; } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } /** * Assign a Sku to a user. * * @param \App\Sku $sku The sku to assign. * @param int $count Count of entitlements to add * * @return \App\User Self * @throws \Exception */ public function assignSku($sku, int $count = 1): User { // TODO: I guess wallet could be parametrized in future $wallet = $this->wallet(); $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); // TODO: Sanity check, this probably should be in preReq() on handlers // or in EntitlementObserver if ($sku->handler_class::entitleableClass() != User::class) { throw new \Exception("Cannot assign non-user SKU ({$sku->title}) to a user"); } while ($count > 0) { \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $sku->units_free >= $exists ? $sku->cost : 0, 'entitleable_id' => $this->id, 'entitleable_type' => User::class ]); $exists++; $count--; } return $this; } /** * Check if current user can delete another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canDelete($object): bool { if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can read data of another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if (!method_exists($object, 'wallet')) { return false; } if ($object instanceof User && $this->id == $object->id) { return true; } $wallet = $object->wallet(); return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can update data of another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { if (!method_exists($object, 'wallet')) { return false; } if ($object instanceof User && $this->id == $object->id) { return true; } return $this->canDelete($object); } /** * List the domains to which this user is entitled. * * @return Domain[] */ public function domains() { $dbdomains = Domain::whereRaw( sprintf( '(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE ) )->get(); $domains = []; foreach ($dbdomains as $dbdomain) { $domains[] = $dbdomain; } foreach ($this->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; \Log::info("Found domain for {$this->email}: {$domain->namespace} (owned)"); $domains[] = $domain; } } foreach ($this->accounts as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; \Log::info("Found domain {$this->email}: {$domain->namespace} (charged)"); $domains[] = $domain; } } return $domains; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Entitlements for this user. * * Note that these are entitlements that apply to the user account, and not entitlements that * this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement', 'entitleable_id', 'id'); } public function addEntitlement($entitlement) { if (!$this->entitlements->contains($entitlement)) { return $this->entitlements()->save($entitlement); } } /** * Helper to find user by email address, whether it is * main email address, alias or external email * * @param string $email Email address * * @return \App\User User model object if found */ public static function findByEmail(string $email): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $alias = UserAlias::where('alias', $email)->first(); if ($alias) { return $alias->user; } // TODO: External email return null; } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } + /** + * Suspend this domain. + * + * @return void + */ + public function suspend(): void + { + if ($this->isSuspended()) { + return; + } + + $this->status |= User::STATUS_SUSPENDED; + $this->save(); + } + + /** + * Unsuspend this domain. + * + * @return void + */ + public function unsuspend(): void + { + if (!$this->isSuspended()) { + return; + } + + $this->status ^= User::STATUS_SUSPENDED; + $this->save(); + } + /** * Return users controlled by the current user. * * Users assigned to wallets the current user controls or owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users() { $wallets = array_merge( $this->wallets()->pluck('id')->all(), $this->accounts()->pluck('wallet_id')->all() ); return $this->select(['users.*', 'entitlements.wallet_id']) ->distinct() ->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', 'App\User'); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Returns the wallet by which the user is controlled * * @return \App\Wallet A wallet object */ public function wallet(): Wallet { $entitlement = $this->entitlement()->first(); // TODO: No entitlement should not happen, but in tests we have // such cases, so we fallback to the user's wallet in this case return $entitlement ? $entitlement->wallet : $this->wallets()->first(); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } } diff --git a/src/database/seeds/UserSeeder.php b/src/database/seeds/UserSeeder.php index 13aa3e41..1bfae731 100644 --- a/src/database/seeds/UserSeeder.php +++ b/src/database/seeds/UserSeeder.php @@ -1,122 +1,136 @@ 'kolab.org', 'status' => Domain::STATUS_NEW + Domain::STATUS_ACTIVE + Domain::STATUS_CONFIRMED + Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL ] ); $john = User::create( [ 'name' => 'John Doe', 'email' => 'john@kolab.org', 'password' => 'simple123', 'email_verified_at' => now() ] ); $john->setSettings( [ 'first_name' => 'John', 'last_name' => 'Doe', 'currency' => 'USD', 'country' => 'US', 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005", 'external_email' => 'john.doe.external@gmail.com', 'phone' => '+1 509-248-1111', ] ); $john->setAliases(['john.doe@kolab.org']); $wallet = $john->wallets->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $package_kolab = \App\Package::where('title', 'kolab')->first(); + $package_lite = \App\Package::where('title', 'lite')->first(); $domain->assignPackage($package_domain, $john); $john->assignPackage($package_kolab); $jack = User::create( [ 'name' => 'Jack Daniels', 'email' => 'jack@kolab.org', 'password' => 'simple123', 'email_verified_at' => now() ] ); $jack->setSettings( [ 'first_name' => 'Jack', 'last_name' => 'Daniels', 'currency' => 'USD', 'country' => 'US' ] ); $jack->setAliases(['jack.daniels@kolab.org']); $john->assignPackage($package_kolab, $jack); foreach ($john->entitlements as $entitlement) { $entitlement->created_at = Carbon::now()->subMonthsWithoutOverflow(1); $entitlement->updated_at = Carbon::now()->subMonthsWithoutOverflow(1); $entitlement->save(); } $ned = User::create( [ 'name' => 'Edward Flanders', 'email' => 'ned@kolab.org', 'password' => 'simple123', 'email_verified_at' => now() ] ); $ned->setSettings( [ 'first_name' => 'Edward', 'last_name' => 'Flanders', 'currency' => 'USD', 'country' => 'US' ] ); $john->assignPackage($package_kolab, $ned); + $ned->assignSku(\App\Sku::where('title', 'activesync')->first(), 1); + // Ned is a controller on Jack's wallet $john->wallets()->first()->addController($ned); // Ned is also our 2FA test user $sku2fa = Sku::firstOrCreate(['title' => '2fa']); $ned->assignSku($sku2fa); SecondFactor::seed('ned@kolab.org'); + $joe = User::create( + [ + 'name' => 'Joe Sixpack', + 'email' => 'joe@kolab.org', + 'password' => 'simple123', + 'email_verified_at' => now() + ] + ); + + $john->assignPackage($package_lite, $joe); + factory(User::class, 10)->create(); } }