diff --git a/doc/RESELLER.md b/doc/RESELLER.md --- a/doc/RESELLER.md +++ b/doc/RESELLER.md @@ -21,7 +21,41 @@ ## Creating tenant/resellers/domains -TODO: How to create tenant, resellers, public domains? +1. Create a new reseller user + +``` +php artisan user:create admin@reseller.kolab.io --role=reseller +``` + +2. Create a tenant + +``` +php artisan tenant:create admin@reseller.kolab.io --title="Reseller Company" +``` + +3. Create a public domain (for customer signups) - this should be executed +on the tenant system. + +``` +php artisan scalpel:domain:create --namespace=reseller.kolab.io --type=1 --status=18 +``` + +4. List all tenants in the system + +``` +php artisan tenants --attr=title +``` + +5. Managing tenant settings + +``` +php artisan tenant:list-settings +php artisan scalpel:tenant-setting:create --tenant_id= --key=mail.sender.address --value=noreply@reseller.kolab.io +php artisan scalpel:tenant-setting:update --value=noreply@reseller.kolab.io +``` +For proper operation some settings need to be set for a tenant. They include: +`app.public_url`, `app.url`, `app.name`, `app.support_url`, +`mail.sender.address`, `mail.sender.name`, `mail.replyto.address`, `mail.replyto.name`. ## Plans and Packages @@ -30,12 +64,29 @@ to define fees and cost for these new SKUs. Also maybe he does not need all of the plans/packages or wants some new ones? -TODO: How? With deployment seeder or CLI commands, admin UI? +The commands below need to be executed on the tenant system. +1. Listing plans/packages -## Fees +``` +php artisan plan:packages +php artisan package:skus +``` + +2. Listing all SKUs + +``` +php artisan skus --attr=title --attr=cost --attr=fee +``` + +3. Modifying SKU -TODO: How to set a fee? With deployment seeder or CLI commands, admin UI? +``` +php artisan scalpel:sku:update --cost=1000 --fee=900 +``` + + +## Fees Every SKU has a cost and fee defined. Both are monetary values (not percents). Cost is what a customer is paying. Fee is what the system owner gets. @@ -51,5 +102,3 @@ Fees are getting applied to the tenant's wallet. Note that currently we're using wallet of the first tenant's reseller user (see `Tenant::wallet()`). I.e. it will have to change if we wanted to allow tenants to have more than one staff member. - -TODO: Examples? diff --git a/src/app/Console/Command.php b/src/app/Console/Command.php --- a/src/app/Console/Command.php +++ b/src/app/Console/Command.php @@ -30,6 +30,40 @@ protected $dangerous = false; + /** + * Apply current tenant context to the query + * + * @param mixed $object The model class object + * + * @return mixed The model class object + */ + protected function applyTenant($object) + { + if ($this->commandPrefix == 'scalpel') { + return $object; + } + + $modelsWithOwner = [ + \App\Wallet::class, + ]; + + $tenantId = \config('app.tenant_id'); + + // Add tenant filter + if (in_array(\App\Traits\BelongsToTenantTrait::class, class_uses($object::class))) { + $object = $object->withEnvTenantContext(); + } elseif (in_array($object::class, $modelsWithOwner)) { + $object = $object->whereExists(function ($query) use ($tenantId) { + $query->select(DB::raw(1)) + ->from('users') + ->whereRaw('wallets.user_id = users.id') + ->whereRaw('users.tenant_id ' . ($tenantId ? "= $tenantId" : 'is null')); + }); + } + + return $object; + } + /** * Shortcut to creating a progress bar of a particular format with a particular message. * @@ -123,29 +157,7 @@ $model = new $objectClass(); } - if ($this->commandPrefix == 'scalpel') { - return $model; - } - - $modelsWithOwner = [ - \App\Wallet::class, - ]; - - $tenantId = \config('app.tenant_id'); - - // Add tenant filter - if (in_array(\App\Traits\BelongsToTenantTrait::class, class_uses($objectClass))) { - $model = $model->withEnvTenantContext(); - } elseif (in_array($objectClass, $modelsWithOwner)) { - $model = $model->whereExists(function ($query) use ($tenantId) { - $query->select(DB::raw(1)) - ->from('users') - ->whereRaw('wallets.user_id = users.id') - ->whereRaw('users.tenant_id ' . ($tenantId ? "= $tenantId" : 'is null')); - }); - } - - return $model; + return $this->applyTenant($model); } /** diff --git a/src/app/Console/Commands/Scalpel/Sku/UpdateCommand.php b/src/app/Console/Commands/Scalpel/Sku/UpdateCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Scalpel/Sku/UpdateCommand.php @@ -0,0 +1,15 @@ +option('password'); $role = $this->option('role'); - list($local, $domainName) = explode('@', $email, 2); + $existingDeletedUser = null; + $packagesToAssign = []; - $domain = $this->getDomain($domainName); + if ($role === User::ROLE_ADMIN || $role === User::ROLE_RESELLER) { + if ($error = $this->validateUserWithRole($email)) { + $this->error($error); + return 1; + } - if (!$domain) { - $this->error("No such domain {$domainName}."); - return 1; - } + // TODO: Assigning user to an existing account + // TODO: Making him an operator of the reseller wallet + } else { + list($local, $domainName) = explode('@', $email, 2); - if ($domain->isPublic()) { - $this->error("Domain {$domainName} is public."); - return 1; - } + $domain = $this->getDomain($domainName); - $owner = $domain->wallet()->owner; - $existingDeletedUser = null; + if (!$domain) { + $this->error("No such domain {$domainName}."); + return 1; + } - // Validate email address - if ($error = UsersController::validateEmail($email, $owner, $existingDeletedUser)) { - $this->error("{$email}: {$error}"); - return 1; - } + if ($domain->isPublic()) { + $this->error("Domain {$domainName} is public."); + return 1; + } - if (!$password) { - $password = \App\Utils::generatePassphrase(); - } + $owner = $domain->wallet()->owner; - $packagesToAssign = []; - foreach ($packages as $package) { - $userPackage = $this->getObject(\App\Package::class, $package, 'title', false); - if (!$userPackage) { - $this->error("Invalid package: {$package}"); + // Validate email address + if ($error = UsersController::validateEmail($email, $owner, $existingDeletedUser)) { + $this->error("{$email}: {$error}"); return 1; } - $packagesToAssign[] = $userPackage; + + foreach ($packages as $package) { + $userPackage = $this->getObject(\App\Package::class, $package, 'title', false); + if (!$userPackage) { + $this->error("Invalid package: {$package}"); + return 1; + } + $packagesToAssign[] = $userPackage; + } + } + + if (!$password) { + $password = \App\Utils::generatePassphrase(); } - //TODO we need a central location for role validation - if ($role && $role != "admin" && $role != "reseller") { - $this->error("Tried to set an invalid role: {$role}"); + try { + $user = new \App\User(); + $user->email = $email; + $user->password = $password; + $user->role = $role; + } catch (\Exception $e) { + $this->error($e->getMessage()); return 1; } @@ -83,21 +101,52 @@ $existingDeletedUser->forceDelete(); } - $user = \App\User::create( - [ - 'email' => $email, - 'password' => $password - ] - ); - $user->role = $role; $user->save(); + if (empty($owner)) { + $owner = $user; + } + foreach ($packagesToAssign as $package) { - $user->assignPackage($package); + $owner->assignPackage($package, $user); } DB::commit(); $this->info((string) $user->id); } + + /** + * Validate email address for a new admin/reseller user + * + * @param string $email Email address + * + * @return ?string Error message + */ + protected function validateUserWithRole($email): ?string + { + // Validate the email address (basicly just the syntax) + $v = Validator::make( + ['email' => $email], + ['email' => ['required', new ExternalEmail()]] + ); + + if ($v->fails()) { + return $v->errors()->toArray()['email'][0]; + } + + // Check if an email is already taken + if ( + User::emailExists($email, true) + || User::aliasExists($email) + || \App\Group::emailExists($email, true) + || \App\Resource::emailExists($email, true) + || \App\SharedFolder::emailExists($email, true) + || \App\SharedFolder::aliasExists($email) + ) { + return "Email address is already in use"; + } + + return null; + } } diff --git a/src/app/Console/ObjectListCommand.php b/src/app/Console/ObjectListCommand.php --- a/src/app/Console/ObjectListCommand.php +++ b/src/app/Console/ObjectListCommand.php @@ -43,6 +43,8 @@ $objects = new $this->objectClass(); } + $objects = $this->applyTenant($objects); + foreach ($this->option('filter') as $filter) { $objects = $this->applyFilter($objects, $filter); } diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -22,13 +22,13 @@ /** * The eloquent definition of a User. * - * @property string $email - * @property int $id - * @property string $password - * @property string $password_ldap - * @property string $role - * @property int $status - * @property int $tenant_id + * @property string $email + * @property int $id + * @property string $password + * @property string $password_ldap + * @property ?string $role + * @property int $status + * @property int $tenant_id */ class User extends Authenticatable { @@ -61,6 +61,9 @@ // a restricted user public const STATUS_RESTRICTED = 1 << 7; + public const ROLE_ADMIN = 'admin'; + public const ROLE_RESELLER = 'reseller'; + /** @var int The allowed states for this object used in StatusPropertyTrait */ private int $allowed_states = self::STATUS_NEW | self::STATUS_ACTIVE | @@ -90,7 +93,8 @@ /** @var array The attributes that can be null */ protected $nullable = [ 'password', - 'password_ldap' + 'password_ldap', + 'role', ]; /** @var array The attributes that should be cast */ @@ -645,6 +649,20 @@ $this->setPasswordAttribute($password); } + /** + * User role mutator + * + * @param ?string $role The user role + */ + public function setRoleAttribute($role) + { + if ($role !== null && !in_array($role, [self::ROLE_ADMIN, self::ROLE_RESELLER])) { + throw new \Exception("Invalid role: {$role}"); + } + + $this->attributes['role'] = $role; + } + /** * Suspend all users/domains/groups in this account. */ diff --git a/src/tests/Feature/Console/Tenant/CreateTest.php b/src/tests/Feature/Console/Tenant/CreateTest.php --- a/src/tests/Feature/Console/Tenant/CreateTest.php +++ b/src/tests/Feature/Console/Tenant/CreateTest.php @@ -58,7 +58,7 @@ $this->assertEmpty($user->role); $this->assertEquals($user->tenant_id, \config('app.tenant_id')); - // User not existing + // Existing user $code = \Artisan::call("tenant:create {$user->email} --title=\"Test Tenant\""); $output = trim(\Artisan::output()); $this->assertSame(0, $code); diff --git a/src/tests/Feature/Console/User/CreateTest.php b/src/tests/Feature/Console/User/CreateTest.php --- a/src/tests/Feature/Console/User/CreateTest.php +++ b/src/tests/Feature/Console/User/CreateTest.php @@ -17,6 +17,7 @@ $this->deleteTestUser('user@kolab.org'); $this->deleteTestUser('admin@kolab.org'); + $this->deleteTestUser('reseller@unknown.domain.tld'); } /** @@ -26,6 +27,7 @@ { $this->deleteTestUser('user@kolab.org'); $this->deleteTestUser('admin@kolab.org'); + $this->deleteTestUser('reseller@unknown.domain.tld'); parent::tearDown(); } @@ -40,6 +42,18 @@ // Warning: We're not using artisan() here, as this will not // allow us to test "empty output" cases + // Invalid email + $code = \Artisan::call("user:create jack..test@kolab.org"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("jack..test@kolab.org: The specified email is invalid.", $output); + + // Non-existing domain + $code = \Artisan::call("user:create jack@kolab"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("No such domain kolab.", $output); + // Existing email $code = \Artisan::call("user:create jack@kolab.org"); $output = trim(\Artisan::output()); @@ -58,19 +72,45 @@ $this->assertSame(1, $code); $this->assertSame("Domain kolabnow.com is public.", $output); - // Valid - $code = \Artisan::call("user:create user@kolab.org"); + // Valid (user) + $code = \Artisan::call("user:create user@kolab.org --package=kolab"); $output = trim(\Artisan::output()); $user = User::where('email', 'user@kolab.org')->first(); $this->assertSame(0, $code); $this->assertEquals($user->id, $output); + $this->assertSame(1, $user->countEntitlementsBySku('mailbox')); + $this->assertSame(1, $user->countEntitlementsBySku('groupware')); + $this->assertSame(5, $user->countEntitlementsBySku('storage')); - // Valid - $code = \Artisan::call("user:create admin@kolab.org --package=kolab --role=admin --password=simple123"); + // Valid (admin) + $code = \Artisan::call("user:create admin@kolab.org --role=admin --password=simple123"); $output = trim(\Artisan::output()); $user = User::where('email', 'admin@kolab.org')->first(); $this->assertSame(0, $code); $this->assertEquals($user->id, $output); - $this->assertEquals($user->role, "admin"); + $this->assertEquals($user->role, User::ROLE_ADMIN); + + // Valid (reseller) + $code = \Artisan::call("user:create reseller@unknown.domain.tld --role=reseller --password=simple123"); + $output = trim(\Artisan::output()); + $user = User::where('email', 'reseller@unknown.domain.tld')->first(); + $this->assertSame(0, $code); + $this->assertEquals($user->id, $output); + $this->assertEquals($user->role, User::ROLE_RESELLER); + + // Invalid role + $code = \Artisan::call("user:create unknwon@kolab.org --role=unknown"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("Invalid role: unknown", $output); + + // Existing email (but with role-reseller) + $code = \Artisan::call("user:create jack@kolab.org --role=reseller"); + $output = trim(\Artisan::output()); + $user = User::where('email', 'reseller@unknown.domain.tld')->first(); + $this->assertSame(1, $code); + $this->assertEquals("Email address is already in use", $output); + + // TODO: Test a case where deleted user exists } } diff --git a/src/tests/Unit/UserTest.php b/src/tests/Unit/UserTest.php --- a/src/tests/Unit/UserTest.php +++ b/src/tests/Unit/UserTest.php @@ -65,6 +65,26 @@ $ldapUser->delete(); } + /** + * Test User role mutator + */ + public function testSetRoleAttribute(): void + { + $user = new User(['email' => 'user@email.com']); + + $user->role = User::ROLE_ADMIN; + $this->assertSame(User::ROLE_ADMIN, $user->role); + + $user->role = User::ROLE_RESELLER; + $this->assertSame(User::ROLE_RESELLER, $user->role); + + $user->role = null; + $this->assertSame(null, $user->role); + + $this->expectException(\Exception::class); + $user->role = 'unknown'; + } + /** * Test basic User funtionality */