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 #430 from multinet-app/workspace-permission-endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
jjnesbitt committed Aug 6, 2020
2 parents 4d3935f + 2b31899 commit 393eecf
Show file tree
Hide file tree
Showing 10 changed files with 200 additions and 54 deletions.
95 changes: 87 additions & 8 deletions multinet/api.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Flask blueprint for Multinet REST API."""
from copy import deepcopy
from dataclasses import asdict
from flasgger import swag_from
from flask import Blueprint, request
from webargs import fields
from webargs.flaskparser import use_kwargs

from typing import Any, Optional, List
from typing import Any, Optional, List, Dict, cast
from multinet.types import EdgeDirection, TableType
from multinet.auth.util import (
require_login,
Expand All @@ -15,6 +17,7 @@
require_owner,
)
from multinet.validation import ValidationFailure, UndefinedKeys, UndefinedTable
from multinet.types import WorkspacePermissions

from multinet import db, util
from multinet.errors import (
Expand All @@ -24,11 +27,69 @@
AlreadyExists,
RequiredParamsMissing,
)
from multinet.user import current_user
from multinet.user import current_user, find_user_from_id

bp = Blueprint("multinet", __name__)


# Included here due to circular imports
# TODO: Remove once implementing new ORM and permission storage
def _permissions_id_to_user(permissons: WorkspacePermissions) -> Dict:
"""
Transform permission documents to directly contain user info.
Currently, `WorkspacePermissons` only contains references to users through their
`sub` values, stored as a str in the role to which it pertains. The client requires
more information to properly display/use permissions, so this function transforms
the `sub` values to the entire user object.
This fuction will eventually be supplanted by a change in our permission model.
"""

# Cast to a regular dict, since it won't actually be
# a `WorkspacePermissions` after we perform replacement
new_permissions = cast(Dict, deepcopy(permissons))

for role, users in new_permissions.items():
if role == "public":
continue

if role == "owner":
# Since the role is "owner", `users` is a `str`
user = find_user_from_id(users)
if user is not None:
new_permissions["owner"] = asdict(user)
else:
new_users = []
for sub in users:
user = find_user_from_id(sub)
if user is not None:
new_users.append(asdict(user))

new_permissions[role] = new_users

return new_permissions


# Included here due to circular imports
# TODO: Remove once implementing new ORM and permission storage
def _permissions_user_to_id(expanded_user_permissions: Dict) -> WorkspacePermissions:
"""Transform permission documents to only contain the `sub` values of users."""
permissions = deepcopy(expanded_user_permissions)

for role, users in permissions.items():
if role == "public":
continue

if role == "owner":
if users is not None:
permissions["owner"] = users["sub"]
else:
permissions[role] = [user["sub"] for user in users]

return cast(WorkspacePermissions, permissions)


@bp.route("/workspaces", methods=["GET"])
@swag_from("swagger/workspaces.yaml")
def get_workspaces() -> Any:
Expand All @@ -41,14 +102,32 @@ def get_workspaces() -> Any:
return stream


@bp.route("/workspaces/<workspace>", methods=["GET"])
@require_reader
@swag_from("swagger/workspace.yaml")
def get_workspace(workspace: str) -> Any:
"""Retrieve a single workspace."""
@bp.route("/workspaces/<workspace>/permissions", methods=["GET"])
@require_maintainer
@swag_from("swagger/get_workspace_permissions.yaml")
def get_workspace_permissions(workspace: str) -> Any:
"""Retrieve the permissions of a workspace."""
metadata = db.get_workspace_metadata(workspace)

return {"name": metadata["name"], "permissions": metadata["permissions"]}
return _permissions_id_to_user(metadata["permissions"])


@bp.route("/workspaces/<workspace>/permissions", methods=["PUT"])
@require_maintainer
@swag_from("swagger/set_workspace_permissions.yaml")
def set_workspace_permissions(workspace: str) -> Any:
"""Set the permissions on a workspace."""
if set(request.json.keys()) != {
"owner",
"maintainers",
"writers",
"readers",
"public",
}:
raise MalformedRequestBody(request.json)

perms = _permissions_user_to_id(request.json)
return _permissions_id_to_user(db.set_workspace_permissions(workspace, perms))


@bp.route("/workspaces/<workspace>/tables", methods=["GET"])
Expand Down
38 changes: 36 additions & 2 deletions multinet/db.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Low-level database operations."""
import os
import copy
from functools import lru_cache

from arango import ArangoClient
Expand All @@ -17,9 +18,15 @@
)
from requests.exceptions import ConnectionError

from typing import Any, List, Dict, Set, Generator, Optional
from typing import Any, List, Dict, Set, Generator, Optional, cast
from typing_extensions import TypedDict
from multinet.types import EdgeDirection, TableType, Workspace, WorkspaceDocument
from multinet.types import (
EdgeDirection,
TableType,
Workspace,
WorkspaceDocument,
WorkspacePermissions,
)
from multinet.auth.types import User
from multinet.errors import InternalServerError
from multinet.validation.csv import validate_csv
Expand Down Expand Up @@ -229,6 +236,33 @@ def get_workspace_metadata(name: str) -> Workspace:
return metadata


def set_workspace_permissions(
name: str, permissions: WorkspacePermissions
) -> WorkspacePermissions:
"""Update the permissions for a given workspace."""
if not workspace_exists(name):
raise WorkspaceNotFound(name)

doc = copy.deepcopy(workspace_mapping(name))
if doc is None:
raise DatabaseCorrupted()

# TODO: Do user object validation once ORM is implemented

# Disallow changing workspace ownership through this function.
new_permissions = copy.deepcopy(permissions)
new_permissions["owner"] = doc["permissions"]["owner"]

doc["permissions"] = new_permissions
return_doc = workspace_mapping_collection().get(
workspace_mapping_collection().update(doc)
)["permissions"]

workspace_mapping.cache_clear()

return cast(WorkspacePermissions, return_doc)


# Caches the reference to the StandardDatabase instance for each workspace
@lru_cache()
def get_workspace_db(name: str, readonly: bool = False) -> StandardDatabase:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
Retrieve a single workspace.
Retrieve the permissions on a workspace.
---
parameters:
- $ref: "#/parameters/workspace"

responses:
200:
description: Metadata about the specified workspace
description: The permissions for the given workspace
schema:
$ref: "#/definitions/workspace"
$ref: "#/definitions/workspace_permissions"

404:
description: Specified workspace could not be found
Expand Down
22 changes: 22 additions & 0 deletions multinet/swagger/set_workspace_permissions.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
Set the permissions on a workspace.
---
parameters:
- $ref: "#/parameters/workspace"
- name: permissions
description: Permission Document
in: body

responses:
200:
description: The permissions for the given workspace
schema:
$ref: "#/definitions/workspace_permissions"

404:
description: Specified workspace could not be found
schema:
type: string
example: workspace_that_doesnt_exist

tags:
- workspace
50 changes: 26 additions & 24 deletions multinet/swagger/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,53 +8,55 @@ definitions:
any_type:
description: Can have any type

workspace:
description: A description of a workspace, including its permissions
workspace_permissions:
description: A description of a workspace's permissions
type: object
required:
- name
- owner
- maintainers
- writers
- readers
- owner
- public
- readers
- writers
properties:
name:
description: The name of the workspace
type: string
owner:
description: The owner of the workspace
type: string
type: object
maintainers:
description: The list of maintainers of the workspace
type: array
items:
type: string
type: object
writers:
description: The list of writers of the workspace
type: array
items:
type: string
type: object
readers:
description: The list of readers of the workspace
type: array
items:
type: string
type: object
public:
description: Whether this workspace is public or not
type: boolean
example:
name: engineering
owner: laforge
readers:
- worf
- data
- troi
maintainers:
- picard
- riker
writers: []
public: False
maintainers: []
owner:
email: guy.fieri@flavortown.com
family_name: Fieri
given_name: Guy
name: Guy Fieri
picture: "https://flowjournal.org/wp-content/uploads/2012/09/Image-1-Guy-Fieri.png"
sub: "123456789"
public: true
readers: []
writers:
- email: bill.murray@imdb.com
family_name: Murray
given_name: Bill
name: Bill Murray
picture: https://i.pinimg.com/originals/35/bf/be/35bfbe3173cafd59c1066fabe9bb84c5.jpg
sub: "987654321"

graph:
description: A description of a graph, including its constituent tables
Expand Down
19 changes: 9 additions & 10 deletions multinet/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,15 @@
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,
},
)

class WorkspacePermissions(TypedDict):
"""Permissions on a Workspace."""

owner: str # this is the `sub` property of the `User` dataclass
maintainers: List[str]
writers: List[str]
readers: List[str]
public: bool


class Workspace(TypedDict):
Expand Down
12 changes: 8 additions & 4 deletions multinet/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,20 @@ def user_exists(userinfo: UserInfo) -> bool:
return load_user(userinfo) is not None


def load_user(userinfo: UserInfo) -> Optional[User]:
"""Return a user doc if it exists, else None."""
def find_user_from_id(sub: str) -> Optional[User]:
"""Directly uses the `sub` property to return a user."""
coll = user_collection()

try:
return from_dict(User, next(coll.find({"sub": userinfo.sub}, limit=1)))
return from_dict(User, next(coll.find({"sub": sub}, limit=1)))
except StopIteration:
return None


def load_user(userinfo: UserInfo) -> Optional[User]:
"""Return a user doc if it exists, else None."""
return find_user_from_id(userinfo.sub)


def updated_user(user: User) -> User:
"""Update a user using the provided user object."""
coll = user_collection()
Expand Down
6 changes: 6 additions & 0 deletions mypy_stubs/arango/collection.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ class Collection:
def keys(self) -> Cursor: ...
def all(self, skip: Optional[Any] = ..., limit: Optional[Any] = ...) -> Cursor: ...
def find(self, filters: Dict, skip: int = None, limit: int = None) -> Cursor: ...
def get(
self,
document: Union[Dict, str],
rev: Optional[str] = None,
check_rev: bool = True,
) -> Dict: ...
def random(self) -> Dict: ...

class StandardCollection(Collection):
Expand Down
2 changes: 1 addition & 1 deletion test/test_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ def test_generated_workspace(managed_workspace, managed_user, server):
"""Test that a generated workspace exists when querying the API."""

with conftest.login(managed_user, server):
resp = server.get(f"/api/workspaces/{managed_workspace}")
resp = server.get(f"/api/workspaces/{managed_workspace}/permissions")
assert resp.status_code == 200


Expand Down
4 changes: 2 additions & 2 deletions test/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
def test_require_reader(server, managed_workspace, managed_user):
"""Test the `require_reader` decorator."""
with conftest.login(managed_user, server):
resp = server.get(f"/api/workspaces/{managed_workspace}")
resp = server.get(f"/api/workspaces/{managed_workspace}/permissions")

assert resp.status_code == 200
assert resp.json["permissions"]["owner"] == managed_user.sub
assert resp.json["owner"]["sub"] == managed_user.sub

0 comments on commit 393eecf

Please sign in to comment.