loading...

Bootstrapping an Admin account in Meteor

jankapunkt profile image Jan Küster ・11 min read

I have worked a lot with Accounts in Meteor and I faced nearly always the same pattern of problem: how to immediately create an Admin account as the first user on a fresh project deployment?

There is no definite one-for-all solution here. Use-cases vary a lot and Meteor's Accounts package is very flexible on how to achieve things. Anyway, over time I finally came up with a solution that I am satisfied with and I want to share it with you in this article.

⌨️ Prepare your IDE and console, since there will be an implementation part, too.

How we should not solve this

Before we blindly jump in, I'd like to analyze my previous attempt on this topic and convert it's conclusion into requirements for an implementation.

The previous bootstrapping procedure was rash and not well designed. It was basically this:

  • On every client-start call a server method, that asks if an Admin exists.

  • The server method checks a dedicated Admins collection for an entry and returned, if one exists or not.

  • If it returned true (=Admin exists), continue to login/home-screen.

  • Otherwise, redirect to a specific form to create the new initial Admin.

I deployed apps with this design a lot (OMG! 🙈). It felt great to start the app the first time and immediately got directed to the form for creating the Admin, then getting auto-logged-in and redirected to the dashboard page.

However, over time I came up with a list of issues, that I either got reported back from users or realized myself:

  • 🔍 Every client, that connects to the app knows immediately, whether an Admin exists or not

  • 💀 In theory it is possible for anyone to create the initial admin, as long as this person knows the url of the app and has the right timing. Although in practice nearly impossible, this is still a potential risk and Mr. Murphy definitely knows better here ;-)

  • 🚦 There is one avoidable server-call at client startup, leading to extra traffic and less time-to-screen (because the client router must wait for the result to decide where to go).

  • 🚗 There is one level of complexity added to the client's routing logic.

  • 📦 There must be an extra template + client logic to provide the form for the input of the initial Admin credentials.

  • ⌛ It requires immediate presence for interaction of the person who is considered Admin as she is required to enter name, email, password etc. (this one is so annoying sometimes).

As you can already see, there are many issues that may not have been that clear in the first place and that I want to avoid at any cost in the future.

At this point, I think it might be a good idea to convert these issues into a more engaging list of requirements:

Requirements for bootstrapping an Admin account

  1. It should skip bootstrapping procedure (without errors), if an Admin already exists.

  2. The credentials of the person, who will be the Admin, must be known at the time of the server startup. She does not need to be present but her credentials need to be added to the deployment configuration. It should throw an error, if the minimum credentials are not provided. Otherwise, if the credentials are valid, it should add the credentials to the newly created Admin user account.

  3. The initial Admin must also be the first user to be created. It should throw an error if the Admin is not the first user. Furthermore, it should throw an error, if the user (via userId), that is supposed to become Admin does not exist.

  4. There must be no default password being configured for the initial Admin. It should create the Admin user without password. This results in denying login until a password is set.

  5. The Admin person is required to set a password using a unique password-reset link, that only she receives. Therefore, it should send an enrolment link to the successfully created Admin user.

  6. There should be no way for any Meteor method or other methods to "know" the configured name and email. Thus, it should delete the Admin credentials from the config, once completed with success.

  7. If any step of this procedure fails, potentially created documents need to be removed (rollback) and an error must be thrown to prevent further startup.

  8. There must be no client involvement in creating the initial Admin document at all. Thus, it should not be able to access the procedure on the client.

With this we should have covered (hopefully) all the issues that came up during the previous solution. In the following section we implement these requirements step-by-step, based on a new and empty project.

Implementation with an empty project

To make this really easy, this example is independent from any client code. You can choose any client framework (Blaze, React, Vue, Angular, Svelte etc.) you want here.

Step 1. Create the project

$ meteor create bootstrap-admin-example
$ cd bootstrap-admin-example
$ meteor npm install
$ meteor remove autopublish insecure
$ meteor add accounts-base accounts-password email

optional: use aldeed:collection2 to validate document inserts

If you want to be sure, nothing gets added to your collections, that doesn't comply with a given schema, you should use aldeed:collection2 (requires SimpleSchema) here:

$ meteor add aldeed:collection2
$ meteor npm install --save simpl-schema

I'd like to keep the guide simple enough to be followed. Therefore, the next steps won't include schema validation. Fortunately, you can check out the example repository - it includes these extended checks.

Step 2. Create a deployment configuration for the Admin account

First, we need to create a settings.json file in the top-level folder of the project. It will contain our Admin definition in a way, that it will not be exposed to the client. Read more on Meteor settings, if you are new to this.

$ touch settings.json

There we add an entry for our initial Admin. Not, that this file must not contain a password. It will be set from the Admin person using a special reset-capability link:

{
  "admin": {
    "firstName": "Jane Q.",
    "lastName": "Citizen",
    "username": "admin",
    "email": "jqcitizen@domain.tld"
  }
}

Step 3. Create an Admins Object and collection

We want to store the fact, that a certain user is an Admin in a separate collection. By doing so, we can decouple users and Admins from each other and there is no entry in Meteor.users about who is admin.

Let's create a simple descriptive Object:

$ mkdir -p imports/accounts
$ touch imports/accounts/Admins.js

imports/accounts/Admins.js

import { Mongo } from 'meteor/mongo'

export const Admins = {
  name: 'admins',
  schema: {
    userId: String
  }
}

const AdminsCollection = new Mongo.Collection(Admins.name)
Admins.collection = () => AdminsCollection

This Object now represents our Admins with a Mongo.Collection instance. The Admins.schema is implicit and rather for information. In the example project you can see, how it is actually used to validate inserted documents.

Step 5. Create the bootstrapAdmin startup module

Now we have include our bootstrapping procedure into our startup routine. Since we won't allow any client to be involved, we make use of Meteor's code-splitting and create this file in the server folder:

$ touch server/bootstrapAdmin.js

This file needs to be imported in server/mains.js in order to be evaluated at startup:

server/main.js

import './bootstrapAdmin'

The bootstrapping will make use of some packages and our Admins module, so let's add them as imports first:

server/bootstrapAdmin.js

import { Meteor } from 'meteor/meteor'
import { Mongo } from 'meteor/mongo'
import { Accounts } from 'meteor/accounts-base'
import { check, Match } from 'meteor/check'
import { Admins } from '../imports/accounts/Admins'

Step 6. Create a before and after condition

Before we implement the creation logic, we need to define some conditions on which we run the script and what we do afterwards.

First, we need to make sure, there is an Admins collection present:

server/bootstrapAdmin.js

//...
import { Admins } from '../imports/accounts/Admins'

const AdminsCollection = Admins.collection()
const isMongoCollection = Match.Where(collection => collection instanceof Mongo.Collection)

check(AdminsCollection, isMongoCollection)

You might think this is not necessary, since the collection must be created when Admins is imported. Nevertheless we want to be sure that everything runs as expected, when it comes to such a sensitive routine.

The next condition is, if we want to run the procedure and what to do afterwards:

server/bootstrapAdmin.js

//...
check(AdminsCollection, isMongoCollection)

function bootstrapAdmin () {
  if (AdminsCollection.find().count() === 0) {
    checkAdminConfig()
    checkFirstUser()
    createAdminAccount()
  }

  purgeSettings()
}

function checkAdminConfig () {}
function checkFirstUser () {}
function createAdminAccount () {}
function purgeSettings () {}
function rollback () {}

bootstrapAdmin()

We run our bootstrapping procedure only in case there is no Admin (req. #1). However, in any case we want to purge the admin config from our settings to not even expose it to our server environment (req. #6).

From here we implement the remaining requirements within the yet empty functions.

Step 7. Implement the requirements

We take on the negative requirements (those, involving errors to be thrown) first. By doing so we exclude any suspicious state before finally creating the Admin account.

7.1 checkAdminConfig

In the first part we validate the integrity of the settings.json (implementing req. #2):

function checkAdminConfig () {
  const nonEmptyString = Match.Where(x => typeof x === 'string' && x.length > 0)
  const adminSettings = Meteor.settings.admin

  check(adminSettings, {
    firstName: nonEmptyString,
    lastName: nonEmptyString,
    username: nonEmptyString,
    email: nonEmptyString
  })
}

We use an extended String check to make sure there is at least something written in the config (req. #2). Note, that we will let Accounts.createUser validate the email and username values. It's implementation is battle-tested and can be relied upon.

7.2 checkFirstUser

In this function we check, whether the Admin is really the first user (req. #3):

function checkFirstUser () {
  const usersCount = Meteor.users.find().count()
  const adminCount = AdminsCollection.find().count()
  const usersExistsBeforeAdmin = adminCount === 0 && usersCount > 0
  const adminExistsBeforeUsers = usersCount === 0 && adminCount > 0

  if (usersExistsBeforeAdmin || adminExistsBeforeUsers) {
    throw new Error(`Unexpected users/admin mismatch: There were ${usersCount} users and ${adminCount} admins.`)
  }
}

As you can see, we cover both cases of mismatches between initial users and Admin. Who knows when any quirky state leads to one of these, so let's rather be sure than be sorry.

7.3 rollback

If our assumption is also right on this, we can now continue to create our Admin user. Since this part involves multiple Mongo operations (and we won't use transactions in this article), we need to call the rollback to ensure we restore the state before:

function rollback (userId, adminId) {
  if (userId) Meteor.users.remove(userId)
  if (adminId) AdminsCollection.remove(adminId)
}

7.4 createAdminAccount

This function must create the Admin account from the config without setting a password (req. #4). If no errors occurred, it should rather send the enrolment mail to this user. I have added some comments on the substeps, since the function is a bit longer:

function createAdminAccount () {
  // 1. create user
  const { firstName, lastName, username, email } = Meteor.settings.admin
  const userId = Accounts.createUser({ username, email })

  // 2. create Admins entry for user
  let adminId
  try {
    // We try / catch this, because there may be an error thrown, if the schema validation fails here.
    // This gives us the ability to rollback before rethrowing the error.
    adminId = AdminsCollection.insert({ userId })
  } catch (insertError) {
    rollback(userId, adminId)
    throw insertError
  }

  // 3. check 'tegrity
  const userExists = userId && Meteor.users.find({ _id: userId }).count() > 0
  const adminExists = adminId && AdminsCollection.find({ _id: adminId }).count() > 0

  if (!userExists || !adminExists) {
    rollback(userId, adminId)
    throw new Error(`Unexpected: failed to create user/admin account. UserId=${userId} AdminId=${adminId}`)
  }

  // 4. update profile
  const profileUpdated = Meteor.users.update(userId, { $set: { firstName, lastName } })
  if (!profileUpdated) {
    rollback(userId, adminId)
    throw new Error(`Expected admin user profile to be updated, got ${profileUpdated}`)
  }

  // 5. optional: set roles here, if you like

  // 6. send enrolment email
  Accounts.sendEnrollmentEmail(userId)
}

We rollback and throw an error on any step, that could have altered the current state. We don't need it when an error occurred, during sending the email. This is because the operation a) won't affect the state and b) the Admin user can still request a password-reset link actively.

7.5 purgeSettings

Finally we need to make sure there is no leak of the information about our Admin account to the environment:

function purgeSettings () {
  delete Meteor.settings.admin
  process.env.METEOR_SETTINGS = JSON.stringify(Meteor.settings)
}

It's up to you, if you think this is necessary or not. I think it's good to be more strict by default.

Run the project

Now it's the time to run our project and see what happens. Open your terminal and start the project with settings.json as our settings parameter:

$ meteor --settings=settings.json

If everything is right you will encounter the following output:

=> Started proxy.                             
=> Started MongoDB.                           
I20200624-15:25:11.019(2)? ====== BEGIN MAIL #0 ======
I20200624-15:25:11.030(2)? (Mail not sent; to enable sending, set the MAIL_URL environment variable.)
I20200624-15:25:11.031(2)? Content-Type: text/plain
I20200624-15:25:11.031(2)? From: Accounts Example <no-reply@example.com>
I20200624-15:25:11.031(2)? To: jqcitizen@domain.tld
I20200624-15:25:11.031(2)? Subject: An account has been created for you on localhost:3000
I20200624-15:25:11.031(2)? Message-ID: <e94e67ce-fd13-a5af-e321-a113077c8861@example.com>
I20200624-15:25:11.031(2)? Content-Transfer-Encoding: quoted-printable
I20200624-15:25:11.032(2)? Date: Wed, 24 Jun 2020 13:25:11 +0000
I20200624-15:25:11.032(2)? MIME-Version: 1.0
I20200624-15:25:11.032(2)? 
I20200624-15:25:11.032(2)? Hello,
I20200624-15:25:11.032(2)? 
I20200624-15:25:11.032(2)? To start using the service, simply click the link below.
I20200624-15:25:11.032(2)? 
I20200624-15:25:11.032(2)? http://localhost:3000/#/enroll-account/NVWRVGhzizds8PxQnzjjVw4KpngqpLQnNy-W=
I20200624-15:25:11.032(2)? WXuFXlp
I20200624-15:25:11.032(2)? 
I20200624-15:25:11.032(2)? Thanks.
I20200624-15:25:11.033(2)? ====== END MAIL #0 ======
I20200624-15:25:11.033(2)? 
I20200624-15:25:11.033(2)? Enrollment email URL: http://localhost:3000/#/enroll-account/NVWRVGhzizds8PxQnzjjVw4KpngqpLQnNy-WWXuFXlp
=> Started your app.

=> App running at: http://localhost:3000/

🎉 🎉 🎉 🎉 🎉 🎉

Looks good, your Admin account has been created and the invitation mail is sent. Let's take a look at the mongo shell:

$ meteor mongo
meteor:PRIMARY> db.users.find().pretty()

This command shows us all users from Meteor.users in pretty-printed format. The output should look like the following:

{
    "_id" : "BhKSSmdX7xvGfmmHJ",
    "createdAt" : ISODate("2020-06-24T13:25:10.993Z"),
    "services" : {
        "password" : {
            "reset" : {
                "token" : "NVWRVGhzizds8PxQnzjjVw4KpngqpLQnNy-WWXuFXlp",
                "email" : "jqcitizen@domain.tld",
                "when" : ISODate("2020-06-24T13:25:11.015Z"),
                "reason" : "enroll"
            }
        }
    },
    "username" : "admin",
    "emails" : [
        {
            "address" : "jqcitizen@domain.tld",
            "verified" : "false"
        }
    ],
    "firstName" : "Jane Q.",
    "lastName" : "Citizen"
}

As we can see, the account has no bcrypt field yet. It will not be set, before the Admin person hasn't set a password via the enrolment link.

Now let's see whats in the Admins collection:

meteor:PRIMARY> db.admins.find().pretty()

Similar to the last command, we pretty-print all entries of the collection. It should look like the following:

{ "_id" : "uqYsvodmRQXJbpDw8", "userId" : "BhKSSmdX7xvGfmmHJ" }

Summary

So there it is. We have successfully bootstrapped our Admin account using a deployment configuration.

You can play around with it and see how it behaves, if you change initial state and parameters (restarting the app, inserting a fake user, inserting a fake Admin etc.).

If you like to see a more extended version with schema validation, I suggest you to checkout the example project.

You can also grab the code from this article or the example project and integrate it into your own projects.

I highly appreciate any feedback and would love to see someone finding a hidden bug or even a vulnerability in this code, making it even more robust for us all. Happy coding!

Posted on by:

jankapunkt profile

Jan Küster

@jankapunkt

Graduated in Digital Media M.Sc. now developing the next generation of educational software. Since a while I develop full stack in Javascript using Meteor. Love fitness and Muay Thai after work.

Discussion

markdown guide
 

Always appreciate your in depth write-ups, keep it up!