class Server: def __init__(self, config: typing.Optional[Config] = None): """Grant host db role credentials to users by email verification. A server gets requests for auth tokens from clients. First, a client sends an email address to the server endpoint. Then, the server generates a one-time link and sends the link to the email address. Next, the client opens the link and stored the revealed auth token to its config. Finally, the client uses the server endpoint and token to request credentials. A server config stores (1) readWrite credentials for a db where it can manage tokens, credential grants to clients, and allow/deny rules; (2) config for sending mail; and (3) host admin credentials to manage user creation/deletion for dbs on those hosts. Args: config (Config): server configuration """ self.cfg = config if config is None: try: self.cfg = Config(check=check, path=path) except ConfigError: self.cfg = Config(check=check, path=path, seed=seed()) # XXX Tried combining @property and @lru_cache decorators for mgdb # et al., but couldn't call e.g. self.mgdb.cache_clear(), so using # private props for caching. self._mgdb = None self._mailer = None self._admin_client = {} def set_mgdb(self, uri): """Set mongogrant database URI. Args: uri (str): MongoDB URI string """ config = self.cfg.load() config["mgdb_uri"] = uri self.cfg.save(config) self._mgdb = None @property def mgdb(self): """mongogrant database. Returns: pymongo.database.Database: server database """ if self._mgdb is None: config = self.cfg.load() uri = config["mgdb_uri"] dbname = uri.split('/')[-1] self._mgdb = MongoClient(config["mgdb_uri"], connect=False)[dbname] return self._mgdb def set_mailer(self, cls, kwargs): """Set class with send(message) method and kwargs to init class. Class cls will be instantiated as cls(**kwargs). cls instance must have `send` method that takes {to,subject,text} args and sends an email to `to` with subject `subject` and body text `text`. Args: cls: mailer class kwargs: kwargs passed to created an instance of cls """ config = self.cfg.load() config["mailer"]["class"] = "{}.{}".format(cls.__module__, cls.__name__) config["mailer"]["kwargs"] = kwargs self.cfg.save(config) self._mailer = None @property def mailer(self): """Mailer for email verification and delivering links to tokens. Returns: Mailer: something that can `send(to,subject,text)` """ if self._mailer is None: mailconf = self.cfg.load()["mailer"] modname = ".".join(mailconf["class"].split('.')[:-1]) clsname = mailconf["class"].split('.')[-1] cls = getattr( __import__(modname, globals(), locals(), [clsname], 0), clsname) self._mailer = cls(**mailconf["kwargs"]) return self._mailer def set_admin_client(self, host: str, username: str, password: str): """Sets admin client auth info in config. Args: host (str): MongoDB host username (str): admin username password (str): admin password """ config = self.cfg.load() config["auth"][host] = ( "mongodb://{u}:{p}@{h}/{d}" .format(u=username, p=password, h=host, d="admin")) self.cfg.save(config) self._admin_client.pop(host, None) def admin_client(self, host): """Return host MongoClient authenticated as admin. Args: host (str): MongoDB host Returns: pymongo.mongo_client.MongoClient: host MongoClient as admin Raises: ConfigError: if authentication fails or cannot connect to host. """ if host not in self._admin_client: config = self.cfg.load() try: client = MongoClient(config["auth"][host], connect=False, serverSelectionTimeoutMS=5000) client.server_info() self._admin_client[host] = client except pymongo.errors.OperationFailure as e: raise ConfigError("Auth error: {}".format(e)) except pymongo.errors.ServerSelectionTimeoutError as e: return ConfigError("Cannot connect: {}".format(e)) return self._admin_client[host] def set_rule(self, email: str, host: str, db: str, role: str, which="allow"): """Allow/deny granting credentials for role on host db to email owner. Args: email (str): email address host (str): host name db (str): database name role (str): "read" or "readWrite" which (str): "allow" or "deny" """ if role not in ("read", "readWrite"): raise ValueError("role must be one of {'read','readWrite'}") if which not in ("allow", "deny"): raise ValueError("which must be one of {'allow','deny'}") self.mgdb[which].update_one(dict(email=email, host=host, role=role), {"$addToSet": {"dbs": db}}, upsert=True) def get_ruler(self, token: str): """Get the spec for which allow/deny rules this token's owner can set. Args: token (str): fetch token Returns: bool: The ruler doc if the owner can set rules, None o/w """ email = self.email_from_fetch_token(token) if email is None: return None return self.mgdb.rulers.find_one({"email": email}) def can_grant(self, email: str, host: str, db: str, role: str): """Can the server grant credentials for role on host db to email owner? An email owner (with a token for that email) may obtain user/pass for role on host db if and only if both: (1) there is no deny rule that matches all of (email,host,db,role) or a subset, i.e. a deny rule for a "read" role also denies a "readWrite" role, and (2) there is an allow rule that matches all of (email,host,db,role) or a superset, i.e. an allow rule for a "readWrite" role also allows a "read" role. Args: email (str): email address host (str): host name db (str): database name role (str): "read" or "readWrite" Returns: bool: Whether server can grant requested credentials. """ config = self.cfg.load() if host not in config["auth"]: return False base_filter = dict(email=email, host=host, dbs=db, role={"$in": [role]}) allow_filter = deepcopy(base_filter) deny_filter = deepcopy(base_filter) if role == "read": allow_filter["role"]["$in"].append("readWrite") elif role == "readWrite": deny_filter["role"]["$in"].append("read") else: raise ValueError("role must be one of {'read','readWrite'}") return (not (self.mgdb.deny.count(deny_filter, limit=1) > 0) and self.mgdb.allow.count(allow_filter, limit=1) > 0) def grant(self, email: str, host: str, db: str, role: str): """Grant and return credentials for role on host db to email owner. Args: email (str): email address host (str): host name db (str): database name role (str): "read" or "readWrite" Returns: dict: {username,password} for role on host db if can grant, None otherwise. """ if not self.can_grant(email, host, db, role): return None grant_filter = dict(email=email, host=host, db=db, role=role) command = ("updateUser" if self.mgdb.grants.count(grant_filter) else "createUser") d = self.admin_client(host)[db] username = "******".format("_".join(email.split('@')), role) password = passphrase() try: d.command(command, username, pwd=password, roles=[role]) except OperationFailure as e: if str(e) == "User {}@{} not found".format(username, db): # User erroneously in self.mgdb.grants collection command = "createUser" d.command(command, username, pwd=password, roles=[role]) except DuplicateKeyError as e: # Trying to create user that already exists on db # and is not known to mongogrant. Leave alone. print(str(e)) return None self.mgdb.grants.update_one( grant_filter, {"$set": dict(username=username)}, upsert=True) return dict(username=username, password=password) def revoke_grants(self, email: str, host: str = "*", db: str = "*", role: str = "*"): """Revoke credential grants to email owner. Drop user(s) on host db(s). Any of host,db,role can be "*", i.e. a wildcard. For example, if host is "hostname" and db is "*" and role is "readWrite", revoke any readWrite credential grants for any db on "hostname". Args: email (str): email address host (str): host name, or "*" for all hosts db (str): database name, or "*" for all dbs role (str): "read", "readWrite" or "*" for all roles """ grant_filter = dict(email=email, host=host, db=db, role=role) for field in ("host", "db", "role"): if grant_filter[field] == "*": grant_filter.pop(field) for doc in self.mgdb.grants.find(grant_filter): db = self.admin_client(doc["host"])[doc["db"]] db.command("dropUser", doc["username"]) self.mgdb.grants.delete_many(grant_filter) def generate_tokens(self, email: str, link_expires: str = "3 d", fetch_expires: str = "30 d"): """Generate link (to confirm email) and fetch (to grant auth) tokens. Generate tokens if there is at least one allow rule for email. Args: email (str): email address. link_expires (str): When, from now, link token expires. Format is "<n> <u>", where <u> is one of "s", "m", "h", or "d" for seconds, minutes, hours, or days, respectively. <n> is a number that will be cast to a float, so it can be e.g. "0.5". fetch_expires (str): When, from link_expires, fetch token expires. Format is same as link_expires. Returns: bool: True if tokens generated, False o/w. """ if self.mgdb.allow.count(dict(email=email), limit=1) == 0: return False unit = dict(s="seconds", m="minutes", h="hours", d="days") n, u = link_expires.split(" ") link_expires = datetime.utcnow() + timedelta(**{unit[u]: float(n)}) n, u = fetch_expires.split(" ") fetch_expires = link_expires + timedelta(**{unit[u]: float(n)}) self.mgdb.tokens.update_one( dict(email=email), {"$push": {"fetch": dict(token=uuid4().hex, expires=fetch_expires), "link": dict(token=uuid4().hex, expires=link_expires)}}, upsert=True) self.delete_expired_tokens() return True def delete_expired_tokens(self): """Delete expired tokens. Also, remove docs with no tokens.""" now = datetime.utcnow() bulk_requests = [] docs = list(self.mgdb.tokens.find( {"$or": [{"link.expires": {"$lte": now}}, {"fetch.expires": {"$lte": now}}, {"link": [], "fetch": []}]})) for d in docs: if not d["link"] and not d["fetch"]: bulk_requests.append(DeleteOne(dict(_id=d["_id"]))) continue link = [] for t in d["link"]: if t["expires"] > now: link.append(t) fetch = [] for t in d["fetch"]: if t["expires"] > now: fetch.append(t) if not link and not fetch: bulk_requests.append(DeleteOne(dict(_id=d["_id"]))) else: bulk_requests.append(ReplaceOne( dict(_id=d["_id"]), dict(email=d["email"], link=link, fetch=fetch) )) if bulk_requests: self.mgdb.tokens.bulk_write(bulk_requests) def email_from_fetch_token(self, token: str): """Retrieve email given fetch token if not expired. Args: token (str): fetch token Returns: str: email address if fetch token not expired, None o/w. """ doc = self.mgdb.tokens.find_one({ "fetch": {"$elemMatch": {"token": token, "expires": {"$gte": datetime.utcnow()}}} }, ["email"]) if not doc: return None return doc["email"] def send_link_token_mail(self, email: str, secure: bool = False, host: str = "localhost", dry_run: bool = False): """Send email to deliver fetch token via one-time link. Args: email (str): email address. secure (bool): whether link should use https or not. host (str): server host including port if not :80. dry_run (bool): whether to send mail or to return message text. Returns: str: "OK" if email sent, message text if dry_run, error message o/w. """ generated = self.generate_tokens(email) if not generated: return ("Email {} not allowed by server. Contact server admin." .format(email)) doc = self.mgdb.tokens.find_one(dict(email=email), ["link"]) if not doc: return ("Email {} allowed by server, but " "error in generating token. Contact server admin") doc["link"].sort(key=lambda t: t["expires"]) link_token = doc["link"][-1]["token"] link = "{}://{}/verifytoken/{}".format( "https" if secure else "http", host, link_token) text = ("Retrieve your mongogrant fetch token by opening this " "one-time link: {}".format(link)) subject = "Mongogrant fetch token from {}".format(host) if not dry_run: self.mailer.send(to=email, subject=subject, text=text) return "OK" else: return text def fetch_token_from_link(self, link_token: str): """Retrieve fetch token given link token, and remove link token. Args: link_token (str): received via email link Returns: str: message with fetch token if link token is valid, error message o/w. """ now = datetime.utcnow() doc = self.mgdb.tokens.find_one({ "link": {"$elemMatch": {"token": link_token, "expires": {"$gte": now}}} }, ["fetch"]) if not doc: return "Link tokens expire. Request again." fetch = sorted(doc["fetch"], key=lambda t: t["expires"])[-1] self.mgdb.tokens.update_one( dict(_id=doc["_id"]), {"$pull": {"link": {"token": link_token}}}) return "Fetch token: {} (expires {} UTC)".format( fetch["token"], fetch["expires"]) def grant_with_token(self, token: str, host: str, db: str, role: str): """Attempt to grant user/pass for role on host db given token. Args: token (str): fetch token host (str): host name db (str): database name role (str): "read" or "readWrite" Returns: dict: {username,password} for role on host db if can grant, None otherwise. """ email = self.email_from_fetch_token(token) if not email: return None return self.grant(email, host, db, role)
def test_save(self): cfg = Config(self.check, self.config_path, self.good_seed) config = cfg.load() config["req_list_field"].append(3) cfg.save(config) self.assertEqual(cfg.load()["req_list_field"][-1], 3)
class Client: def __init__(self, config: typing.Optional[Config] = None): """Get/set config for remotes, aliases, and auth. Args: config (Config): client configuration """ self.cfg = config if config is None: try: self.cfg = Config(check=check, path=path) except ConfigError: self.cfg = Config(check=check, path=path, seed=seed()) def set_remote(self, endpoint: str, token: str): """Set endpoint URL and auth token to retrieve database credentials. Args: endpoint (str): URL of server endpoint token (str): used to authenticate with server """ config = self.cfg.load() rems = config["fetch"]["remotes"] for r in rems: if r["endpoint"] == endpoint: r["token"] = token break else: if endpoint.endswith("/"): endpoint = endpoint[:-1] rems.append(dict(endpoint=endpoint, token=token)) self.cfg.save(config) def remotes(self): """Get list of remotes. Returns: list: of dicts, each with `endpoint` and `token` keys. """ return self.cfg.load()["fetch"]["remotes"] def set_alias(self, alias: str, actual: str, which="host"): """Set alias for host or db so that e.g. FQDNs needn't be hard-coded. Args: alias (str): Nickname for host or db, e.g. "dev", "prod", etc. actual (str): Actual name of host or db, e.g. "server.example.com". which (str): "host" or "db". """ config = self.cfg.load() config["fetch"]["{}_aliases".format(which)][alias] = actual self.cfg.save(config) def aliases(self, which="host"): """Get mapping of host_aliases or db_aliases (key->alias, value->actual). Args: which (str): "host" or "db" Returns: dict: mapping of alias to actual value. """ return self.cfg.load()["fetch"]["{}_aliases".format(which)] @classmethod def check_auth(cls, auth: dict): """Check auth info for host db role credentials. Args: auth (dict): config spec Raises: AuthError: If authentication fails, if read role can write, or if readWrite role cannot write. """ kwargs = dict( host=auth["host"], username=auth["username"], password=auth["password"], authSource=auth["db"], ) client = MongoClient(**kwargs) db = client[auth["db"]] cname = "test_{}".format(uuid4()) try: db[cname].insert_one({"test": 1}) db.drop_collection(cname) if auth["role"] == "read": raise AuthError("Read-only user can write to database") except pymongo.errors.OperationFailure: if auth["role"] == "readWrite": raise AuthError("Read-write user cannot write to database") def set_auth(self, host: str, db: str, role: str, username: str, password: str, check=False): """Set auth info for role (read-only or read-write) on host db. Args: host (str): Host name, e.g. "my.server.com", "my.server.com:27018". Port 27017 is assumed unless otherwise specified. db (str): Database name on host. role (str): one of {"read", "readWrite"}. username (str): User for host db. password (str): Password for host db. check (bool): Whether to validate the auth info, i.e. try to connect to db and verify role. Raises: AuthError: If authentication fails, if read role can write, or if readWrite role cannot write. """ auth = dict(host=host, db=db, role=role, username=username, password=password) if check: self.check_auth(auth) config = self.cfg.load() for a in config["auth"]: if a["host"] == host and a["db"] == db and a["role"] == role: a["username"] = username a["password"] = password break else: config["auth"].append(auth) self.cfg.save(config) def get_auth(self, host: str, db: str, role: str, as_uri=False): """Get auth credentials for role on host db. Args: host (str): Host name, e.g. "my.server.com", "my.server.com:27018". Port 27017 is assumed unless otherwise specified. Can also pass a host alias. db (str): Database name on host. Can also pass a db alias. role (str): one of {"read", "readWrite"} or aliases {"ro", "rw"}. as_uri (bool): format return value as MongoDB URI string. Returns: dict: of {host,db,role,user,password}, or MongoDB URI of type str, if auth entry exists. Returns None otherwise. """ config = self.cfg.load() hosts, dbs = self.aliases("host"), self.aliases("db") if host in hosts: host = hosts[host] if db in dbs: db = dbs[db] if role == "ro": role = "read" elif role == "rw": role = "readWrite" elif role not in {"read", "readWrite"}: raise ValueError("role not in {'read', 'readWrite'}") for a in config["auth"]: if a["host"] == host and a["db"] == db and a["role"] == role: if as_uri: return ( "mongodb://{username}:{password}@{host}/{db}".format( **a)) else: return a print("No credentials for {}:{}/{} found in local config".format( role, host, db)) for remote in self.remotes(): print("Requesting credentials from {}".format(remote["endpoint"])) url = "{endpoint}/grant/{token}".format(**remote) rv = requests.post(url, dict(host=host, db=db, role=role)) if rv.status_code == 200: d = rv.json() print("Found credentials. Saving to local config...") self.set_auth(host, db, role, d["username"], d["password"], check=True) return self.get_auth(host, db, role) else: print("{}".format(rv.json())) return None def db(self, spec: str, **mongoclient_kwargs): """Get a pymongo Database object from a spec "<role>:<host>/<db>." Args: spec (str): of the format <role>:<host>/<db>, where: role is one of {"read", "readWrite"} or aliases {"ro", "rw"}; host is a db host (w/ optional port) or alias; and db is a db on that host, or alias. mongoclient_kwargs (dict): Extra keyword arguments to pass to invocation of MongoClient. Returns: pymongo.database.Database: from spec Raises: AuthError: If no valid auth credentials are available from local config or via remotes to connect to database. """ auth = self.get_db_auth_from_spec(spec) return MongoClient( **dict(**auth, **mongoclient_kwargs))[auth["authSource"]] def get_db_auth_from_spec(self, spec: str): """Read the Mongo authentication information from a spec "<role>:<host>/<db>." Args: spec (str): of the format <role>:<host>/<db>, where: role is one of {"read", "readWrite"} or aliases {"ro", "rw"}; host is a db host (w/ optional port) or alias; and db is a db on that host, or alias. Returns: dict: authentication information from spec """ role, host_db = spec.split(':', 1) host, dbname_or_alias = host_db.split('/', 1) auth = self.get_auth(host, dbname_or_alias, role) if auth is None: raise AuthError( "No valid auth credentials are accessible, either from " "local config or via remotes, to connect to " "database.") auth = auth.copy() dbname = auth["db"] auth["authSource"] = dbname auth.pop("db") auth.pop("role") return auth