Skip to content
This repository has been archived by the owner on Feb 23, 2022. It is now read-only.

Commit

Permalink
Merge pull request #396 from multinet-app/workspace-permission
Browse files Browse the repository at this point in the history
Filter workspaces based on user permissions
  • Loading branch information
waxlamp committed Jun 26, 2020
2 parents 4ed69de + 8b3bd27 commit 8566fe0
Show file tree
Hide file tree
Showing 9 changed files with 224 additions and 21 deletions.
77 changes: 77 additions & 0 deletions devops/scripts/ensure_workspace_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from arango import ArangoClient
import getpass
import sys

from mypy_extensions import TypedDict


HostAnalysis = TypedDict(
"HostAnalysis", {"protocol": str, "hostname": str, "port": int}
)


def analyze_host(host: str) -> HostAnalysis:
if host[:8] == "https://":
protocol = "https"
elif host[:7] == "http://":
protocol = "http"
else:
print(f"bad protocol: {host}", file=sys.stderr)
raise RuntimeError

parts = host[len(f"{protocol}://") :].split(":")
hostname = parts[0]

try:
port = int(parts[1])
except IndexError:
port = 8529
except ValueError:
print(f"bad port: {parts[1]}", file=sys.stderr)
raise RuntimeError

return {"protocol": protocol, "hostname": hostname, "port": port}


def main():
if len(sys.argv) < 2:
print("usage: ensure_workspace_metadata.py <arango-host>", file=sys.stderr)
return 1

# Split apart the host parameter into constituents.
try:
args = analyze_host(sys.argv[1])
except RuntimeError:
return 1

# Create a connection to the database.
client = ArangoClient(
protocol=args["protocol"], host=args["hostname"], port=args["port"]
)

# Get a password from the user.
password = getpass.getpass("Password: ")

# Retrieve the workspace mapping collection from the system database.
db = client.db(name="_system", password=password)
coll = db.collection("workspace_mapping")

# Loop through the documents and correct ones with a missing "permissions"
# field.
for doc in coll.all():
if "permissions" not in doc:
doc["permissions"] = {
"owner": "",
"maintainers": [],
"writers": [],
"readers": [],
"public": True,
}

print(f"updating {doc['name']}...", end="")
db.update_document(doc)
print("done")


if __name__ == "__main__":
sys.exit(main())
21 changes: 18 additions & 3 deletions multinet/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from typing import Any, Optional, List
from multinet.types import EdgeDirection, TableType
from multinet.auth.util import require_login, is_reader
from multinet.validation import ValidationFailure, UndefinedKeys, UndefinedTable

from multinet import db, util
Expand All @@ -16,6 +17,7 @@
AlreadyExists,
RequiredParamsMissing,
)
from multinet.user import current_user

bp = Blueprint("multinet", __name__)

Expand All @@ -24,7 +26,12 @@
@swag_from("swagger/workspaces.yaml")
def get_workspaces() -> Any:
"""Retrieve list of workspaces."""
return util.stream(db.get_workspaces())
user = current_user()

# Filter all workspaces based on whether it should be shown to the user who
# is logged in.
stream = util.stream(w["name"] for w in db.get_workspaces() if is_reader(user, w))
return stream


@bp.route("/workspaces/<workspace>", methods=["GET"])
Expand Down Expand Up @@ -109,11 +116,19 @@ def get_node_edges(


@bp.route("/workspaces/<workspace>", methods=["POST"])
@require_login
@swag_from("swagger/create_workspace.yaml")
def create_workspace(workspace: str) -> Any:
"""Create a new workspace."""
db.create_workspace(workspace)
return workspace

# The `require_login()` decorator ensures that a user is logged in by this
# point.
user = current_user()
assert user is not None

# Perform the actual backend update to create a new workspace owned by the
# logged in user.
return db.create_workspace(workspace, user)


@bp.route("/workspaces/<workspace>/aql", methods=["POST"])
Expand Down
13 changes: 8 additions & 5 deletions multinet/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
import json
from werkzeug.wrappers import Response as ResponseWrapper

from multinet.user import load_user_from_cookie, filtered_user, delete_user_cookie

MULTINET_COOKIE = "multinet-token"
from multinet.user import (
MULTINET_COOKIE,
user_from_cookie,
filtered_user,
delete_user_cookie,
)

bp = Blueprint("user", "user")

Expand All @@ -24,7 +27,7 @@ def user_info() -> ResponseWrapper:
if cookie is None:
return logged_out

user = load_user_from_cookie(cookie)
user = user_from_cookie(cookie)
if user is None:
session.pop(MULTINET_COOKIE, None)
return logged_out
Expand All @@ -41,7 +44,7 @@ def logout() -> ResponseWrapper:
cookie = session.pop(MULTINET_COOKIE, None)
if cookie is not None:
# Load the user model and invalidate its session.
user = load_user_from_cookie(cookie)
user = user_from_cookie(cookie)
if user is not None:
delete_user_cookie(user)

Expand Down
2 changes: 1 addition & 1 deletion multinet/auth/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@
from webargs import fields

from multinet.user import (
MULTINET_COOKIE,
load_user,
updated_user,
get_user_cookie,
set_user_cookie,
register_user,
filter_user_info,
)
from multinet.auth import MULTINET_COOKIE
from multinet.auth.types import GoogleUserInfo, User

from typing import Dict, Optional
Expand Down
44 changes: 44 additions & 0 deletions multinet/auth/util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Utility functions for auth."""

from typing import Any, Optional, Callable

from multinet.errors import Unauthorized
from multinet.types import Workspace
from multinet.auth.types import UserInfo
from multinet.user import current_user


# NOTE: unfortunately, it is difficult to write a type signature for this
# decorator. I've opened an issue to ask about this here:
# https://github.com/python/mypy/issues/9032.
def require_login(f: Callable) -> Callable:
"""Decorate an API endpoint to check for a logged in user."""

def wrapper(*args: Any, **kwargs: Any) -> Any:
user = current_user()
if user is None:
raise Unauthorized("You must be logged in to perform this action")

return f(*args, **kwargs)

return wrapper


def is_reader(user: Optional[UserInfo], workspace: Workspace) -> bool:
"""Indicate whether `user` has read permissions for `workspace`."""
perms = workspace["permissions"]

# A non-logged-in user, by definition, is a reader of public workspaces.
if user is None:
return perms["public"]

# Otherwise, check to see if the workspace is public, or the user is at
# least a Reader of the workspace.
sub = user.sub
return (
perms["public"]
or sub in perms["readers"]
or sub in perms["writers"]
or sub in perms["maintainers"]
or perms["owner"] == sub
)
45 changes: 35 additions & 10 deletions multinet/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@

from typing import Any, List, Dict, Set, Generator, Union
from typing_extensions import TypedDict
from multinet.types import EdgeDirection, TableType
from multinet.types import EdgeDirection, TableType, Workspace
from multinet.auth.types import User
from multinet.errors import InternalServerError
from multinet.util import generate_arango_workspace_name
from multinet import util

from multinet.errors import (
BadQueryArgument,
Expand Down Expand Up @@ -114,26 +115,50 @@ def workspace_exists_internal(name: str) -> bool:
return sysdb.has_database(name)


def create_workspace(name: str) -> None:
"""Create a new workspace named `name`."""
def create_workspace(name: str, user: User) -> str:
"""Create a new workspace named `name`, owned by `user`."""

# Bail out with a 409 if the workspace exists already.
if workspace_exists(name):
raise AlreadyExists("Workspace", name)

coll = workspace_mapping_collection()
new_doc = {"name": name, "internal": generate_arango_workspace_name()}
coll.insert(new_doc)
# Create a workspace mapping document to represent the new workspace. This
# document (1) sets the external name of the workspace to the requested
# name, (2) sets the internal name to a random string, and (3) makes the
# specified user the owner of the workspace.
ws_doc: Workspace = {
"name": name,
"internal": util.generate_arango_workspace_name(),
"permissions": {
"owner": user.sub,
"maintainers": [],
"writers": [],
"readers": [],
"public": False,
},
}

# Attempt to create an Arango database to serve as the workspace itself.
# There is an astronomically negligible chance that the internal name would
# clash with an existing internal name; in this case we go full UNIX and
# just bail out, rather than building in logic to catch it happening.
try:
db("_system").create_database(new_doc["internal"])
db("_system").create_database(ws_doc["internal"])
except DatabaseCreateError:
# Could only happen if there's a name collisison
raise InternalServerError()

# Retrieve the workspace mapping collection and log the workspace metadata
# record.
coll = workspace_mapping_collection()
coll.insert(ws_doc)

# Invalidate the cache for things changed by this function
workspace_mapping.cache_clear()
get_workspace_db.cache_clear()

return name


def rename_workspace(old_name: str, new_name: str) -> None:
"""Rename a workspace."""
Expand Down Expand Up @@ -207,10 +232,10 @@ def get_table_collection(workspace: str, table: str) -> StandardCollection:
return space.collection(table)


def get_workspaces() -> Generator[str, None, None]:
def get_workspaces() -> Generator[Workspace, None, None]:
"""Return a list of all workspace names."""
coll = workspace_mapping_collection()
return (doc["name"] for doc in coll.all())
return (doc for doc in coll.all())


def workspace_tables(
Expand Down
12 changes: 12 additions & 0 deletions multinet/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ def flask_response(self) -> FlaskTuple:
return ("", "500 Internal Server Error")


class Unauthorized(ServerError):
"""The request did not indicate sufficient permission."""

def __init__(self, reason: str = ""):
"""Initialize the error with an optional reason."""
self.reason = reason

def flask_response(self) -> FlaskTuple:
"""Generate a 401 error."""
return (self.reason, "401 Unauthorized")


class NotFound(ServerError):
"""Base exception for 404 errors of various types."""

Expand Down
17 changes: 16 additions & 1 deletion multinet/types.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
"""Custom types for Multinet codebase."""
from typing import Dict, Set
from typing import Dict, Set, List
from typing_extensions import Literal, TypedDict

EdgeDirection = Literal["all", "incoming", "outgoing"]
TableType = Literal["all", "node", "edge"]

WorkspacePermissions = TypedDict(
"WorkspacePermissions",
{
"owner": str, # this is the `sub` property of the `User` dataclass
"maintainers": List[str],
"writers": List[str],
"readers": List[str],
"public": bool,
},
)

Workspace = TypedDict(
"Workspace", {"name": str, "internal": str, "permissions": WorkspacePermissions}
)


class EdgeTableProperties(TypedDict):
"""Describes gathered information about an edge table."""
Expand Down
14 changes: 13 additions & 1 deletion multinet/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from uuid import uuid4
from arango.collection import StandardCollection
from dacite import from_dict
from flask import session

from multinet.db import db
from multinet.errors import InternalServerError
Expand All @@ -17,6 +18,8 @@

from typing import Optional, Dict

MULTINET_COOKIE = "multinet-token"


def user_collection() -> StandardCollection:
"""Return the collection that contains user documents."""
Expand Down Expand Up @@ -82,7 +85,7 @@ def delete_user_cookie(user: User) -> User:
return updated_user(user_copy)


def load_user_from_cookie(cookie: str) -> Optional[User]:
def user_from_cookie(cookie: str) -> Optional[User]:
"""Use provided cookie to load a user, return None if they dont exist."""
coll = user_collection()

Expand All @@ -92,6 +95,15 @@ def load_user_from_cookie(cookie: str) -> Optional[User]:
return None


def current_user() -> Optional[User]:
"""Return the logged in user (if any) from the current session."""
cookie = session.get(MULTINET_COOKIE)
if cookie is None:
return None

return user_from_cookie(cookie)


def get_user_cookie(user: User) -> str:
"""Return the cookie from the user object, or create it if it doesn't exist."""

Expand Down

0 comments on commit 8566fe0

Please sign in to comment.