Merge branch 'master' into require-personal-info

This commit is contained in:
patricksmithdds
2018-10-31 14:12:55 -04:00
committed by GitHub
27 changed files with 661 additions and 85 deletions

View File

@@ -6,8 +6,7 @@ from atst.domain.exceptions import UnauthorizedError
class Authorization(object):
@classmethod
def has_workspace_permission(cls, user, workspace, permission):
workspace_user = WorkspaceUsers.get(workspace.id, user.id)
return permission in workspace_user.permissions()
return permission in WorkspaceUsers.workspace_user_permissions(workspace, user)
@classmethod
def has_atat_permission(cls, user, permission):

View File

@@ -0,0 +1,70 @@
import datetime
from sqlalchemy.orm.exc import NoResultFound
from atst.database import db
from atst.models.invitation import Invitation, Status as InvitationStatus
from atst.domain.workspace_users import WorkspaceUsers
from .exceptions import NotFoundError
class InvitationError(Exception):
def __init__(self, invite):
self.invite = invite
@property
def message(self):
return "{} has a status of {}".format(self.invite.id, self.invite.status.value)
class Invitations(object):
# number of minutes a given invitation is considered valid
EXPIRATION_LIMIT_MINUTES = 360
@classmethod
def _get(cls, token):
try:
invite = db.session.query(Invitation).filter_by(token=token).one()
except NoResultFound:
raise NotFoundError("invite")
return invite
@classmethod
def create(cls, workspace_role, inviter, user):
invite = Invitation(
workspace_role=workspace_role,
inviter=inviter,
user=user,
status=InvitationStatus.PENDING,
expiration_time=Invitations.current_expiration_time(),
)
db.session.add(invite)
db.session.commit()
return invite
@classmethod
def accept(cls, token):
invite = Invitations._get(token)
if invite.is_expired:
invite.status = InvitationStatus.REJECTED
elif invite.is_pending:
invite.status = InvitationStatus.ACCEPTED
db.session.add(invite)
db.session.commit()
if invite.is_revoked or invite.is_rejected:
raise InvitationError(invite)
WorkspaceUsers.enable(invite.workspace_role)
return invite
@classmethod
def current_expiration_time(cls):
return datetime.datetime.now() + datetime.timedelta(
minutes=Invitations.EXPIRATION_LIMIT_MINUTES
)

View File

@@ -89,3 +89,12 @@ class Users(object):
db.session.commit()
return user
@classmethod
def finalize(cls, user):
user.provisional = False
db.session.add(user)
db.session.commit()
return user

View File

@@ -1,7 +1,7 @@
from sqlalchemy.orm.exc import NoResultFound
from atst.database import db
from atst.models.workspace_role import WorkspaceRole
from atst.models.workspace_role import WorkspaceRole, Status as WorkspaceRoleStatus
from atst.models.workspace_user import WorkspaceUser
from atst.models.user import User
@@ -30,6 +30,30 @@ class WorkspaceUsers(object):
return WorkspaceUser(user, workspace_role)
@classmethod
def _get_active_workspace_role(cls, workspace_id, user_id):
try:
return (
db.session.query(WorkspaceRole)
.join(User)
.filter(User.id == user_id, WorkspaceRole.workspace_id == workspace_id)
.filter(WorkspaceRole.status == WorkspaceRoleStatus.ACTIVE)
.one()
)
except NoResultFound:
return None
@classmethod
def workspace_user_permissions(cls, workspace, user):
workspace_role = WorkspaceUsers._get_active_workspace_role(
workspace.id, user.id
)
atat_permissions = set(user.atat_role.permissions)
workspace_permissions = (
[] if workspace_role is None else workspace_role.role.permissions
)
return set(workspace_permissions).union(atat_permissions)
@classmethod
def _get_workspace_role(cls, user, workspace_id):
try:
@@ -123,3 +147,10 @@ class WorkspaceUsers(object):
db.session.commit()
return workspace_users
@classmethod
def enable(cls, workspace_role):
workspace_role.status = WorkspaceRoleStatus.ACTIVE
db.session.add(workspace_role)
db.session.commit()

View File

@@ -4,7 +4,7 @@ from atst.database import db
from atst.domain.common import Query
from atst.domain.exceptions import NotFoundError
from atst.models.workspace import Workspace
from atst.models.workspace_role import WorkspaceRole
from atst.models.workspace_role import WorkspaceRole, Status as WorkspaceRoleStatus
class WorkspacesQuery(Query):
@@ -25,9 +25,10 @@ class WorkspacesQuery(Query):
db.session.query(Workspace)
.join(WorkspaceRole)
.filter(WorkspaceRole.user == user)
.filter(WorkspaceRole.status == WorkspaceRoleStatus.ACTIVE)
.all()
)
@classmethod
def create_workspace_role(cls, user, role, workspace):
return WorkspaceRole(user=user, role=role, workspace=workspace)
def create_workspace_role(cls, user, role, workspace, **kwargs):
return WorkspaceRole(user=user, role=role, workspace=workspace, **kwargs)

View File

@@ -3,6 +3,7 @@ from atst.domain.authz import Authorization
from atst.models.permissions import Permissions
from atst.domain.users import Users
from atst.domain.workspace_users import WorkspaceUsers
from atst.models.workspace_role import Status as WorkspaceRoleStatus
from .query import WorkspacesQuery
from .scopes import ScopedWorkspace
@@ -13,7 +14,9 @@ class Workspaces(object):
def create(cls, request, name=None):
name = name or request.displayname
workspace = WorkspacesQuery.create(request=request, name=name)
Workspaces._create_workspace_role(request.creator, workspace, "owner")
Workspaces._create_workspace_role(
request.creator, workspace, "owner", status=WorkspaceRoleStatus.ACTIVE
)
WorkspacesQuery.add_and_commit(workspace)
return workspace
@@ -86,6 +89,7 @@ class Workspaces(object):
last_name=data["last_name"],
email=data["email"],
atat_role_name="default",
provisional=True,
)
return Workspaces.add_member(workspace, new_user, data["workspace_role"])
@@ -106,9 +110,13 @@ class Workspaces(object):
return WorkspaceUsers.update_role(member, workspace.id, role_name)
@classmethod
def _create_workspace_role(cls, user, workspace, role_name):
def _create_workspace_role(
cls, user, workspace, role_name, status=WorkspaceRoleStatus.PENDING
):
role = Roles.get(role_name)
workspace_role = WorkspacesQuery.create_workspace_role(user, role, workspace)
workspace_role = WorkspacesQuery.create_workspace_role(
user, role, workspace, status=status
)
WorkspacesQuery.add_and_commit(workspace_role)
return workspace_role

View File

@@ -18,3 +18,4 @@ from .request_revision import RequestRevision
from .request_review import RequestReview
from .request_internal_comment import RequestInternalComment
from .audit_event import AuditEvent
from .invitation import Invitation

71
atst/models/invitation.py Normal file
View File

@@ -0,0 +1,71 @@
import datetime
from enum import Enum
import secrets
from sqlalchemy import Column, ForeignKey, Enum as SQLAEnum, TIMESTAMP, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from atst.models import Base, types
from atst.models.mixins.timestamps import TimestampsMixin
from atst.models.mixins.auditable import AuditableMixin
class Status(Enum):
ACCEPTED = "accepted"
REVOKED = "revoked"
PENDING = "pending"
REJECTED = "rejected"
class Invitation(Base, TimestampsMixin, AuditableMixin):
__tablename__ = "invitations"
id = types.Id()
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
user = relationship("User", backref="invitations", foreign_keys=[user_id])
workspace_role_id = Column(
UUID(as_uuid=True), ForeignKey("workspace_roles.id"), index=True
)
workspace_role = relationship("WorkspaceRole", backref="invitations")
inviter_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
inviter = relationship("User", backref="sent_invites", foreign_keys=[inviter_id])
status = Column(SQLAEnum(Status, native_enum=False, default=Status.PENDING))
expiration_time = Column(TIMESTAMP(timezone=True))
token = Column(String(), index=True, default=lambda: secrets.token_urlsafe())
def __repr__(self):
return "<Invitation(user='{}', workspace_role='{}', id='{}')>".format(
self.user_id, self.workspace_role_id, self.id
)
@property
def is_accepted(self):
return self.status == Status.ACCEPTED
@property
def is_revoked(self):
return self.status == Status.REVOKED
@property
def is_pending(self):
return self.status == Status.PENDING
@property
def is_rejected(self):
return self.status == Status.REJECTED
@property
def is_expired(self):
return datetime.datetime.now(self.expiration_time.tzinfo) > self.expiration_time
@property
def workspace(self):
if self.workspace_role:
return self.workspace_role.workspace

View File

@@ -1,4 +1,4 @@
from sqlalchemy import String, ForeignKey, Column, Date
from sqlalchemy import String, ForeignKey, Column, Date, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID
@@ -25,6 +25,8 @@ class User(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
citizenship = Column(String)
designation = Column(String)
date_latest_training = Column(Date)
provisional = Column(Boolean)
REQUIRED_FIELDS = [
"email",

View File

@@ -49,6 +49,6 @@ class Workspace(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
return self.id
def __repr__(self):
return "<Workspace(name='{}', request='{}', task_order='{}', user_count='{}', id='{}')>".format(
self.name, self.request_id, self.task_order.number, self.user_count, self.id
return "<Workspace(name='{}', request='{}', user_count='{}', id='{}')>".format(
self.name, self.request_id, self.user_count, self.id
)

View File

@@ -1,4 +1,5 @@
from sqlalchemy import Index, ForeignKey, Column
from enum import Enum
from sqlalchemy import Index, ForeignKey, Column, Enum as SQLAEnum
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
@@ -6,6 +7,12 @@ from atst.models import Base, mixins
from .types import Id
class Status(Enum):
ACTIVE = "active"
DISABLED = "disabled"
PENDING = "pending"
class WorkspaceRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
__tablename__ = "workspace_roles"
@@ -22,6 +29,8 @@ class WorkspaceRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=False
)
status = Column(SQLAEnum(Status, native_enum=False, default=Status.PENDING))
def __repr__(self):
return "<WorkspaceRole(role='{}', workspace='{}', user_id='{}', id='{}')>".format(
self.role.name, self.workspace.name, self.user_id, self.id

View File

@@ -9,13 +9,6 @@ class WorkspaceUser(object):
self.user = user
self.workspace_role = workspace_role
def permissions(self):
atat_permissions = set(self.user.atat_role.permissions)
workspace_permissions = (
[] if self.workspace_role is None else self.workspace_role.role.permissions
)
return set(workspace_permissions).union(atat_permissions)
@property
def workspace(self):
return self.workspace_role.workspace
@@ -74,8 +67,8 @@ class WorkspaceUser(object):
def __repr__(self):
return "<WorkspaceUser(user='{}', role='{}', workspace='{}', num_environment_roles='{}')>".format(
self.user_name,
self.role.name,
self.user.full_name,
self.role,
self.workspace.name,
self.num_environment_roles,
)

View File

@@ -101,6 +101,10 @@ def login_redirect():
auth_context = _make_authentication_context()
auth_context.authenticate()
user = auth_context.get_user()
if user.provisional:
Users.finalize(user)
session["user_id"] = user.id
return redirect(redirect_after_login_url())

View File

@@ -3,6 +3,7 @@ from flask_wtf.csrf import CSRFError
import werkzeug.exceptions as werkzeug_exceptions
import atst.domain.exceptions as exceptions
from atst.domain.invitations import InvitationError
def make_error_pages(app):
@@ -41,4 +42,15 @@ def make_error_pages(app):
500,
)
@app.errorhandler(InvitationError)
# pylint: disable=unused-variable
def invalid_invitation(e):
log_error(e)
return (
render_template(
"error.html", message="The invitation link you clicked is invalid."
),
404,
)
return app

View File

@@ -24,6 +24,8 @@ from atst.forms.workspace import WorkspaceForm
from atst.forms.data import ENVIRONMENT_ROLES, ENV_ROLE_MODAL_DESCRIPTION
from atst.domain.authz import Authorization
from atst.models.permissions import Permissions
from atst.domain.invitations import Invitations
from atst.queue import queue
bp = Blueprint("workspaces", __name__)
@@ -218,6 +220,15 @@ def new_member(workspace_id):
)
def send_invite_email(owner_name, token, new_member_email):
body = render_template("emails/invitation.txt", owner=owner_name, token=token)
queue.send_mail(
[new_member_email],
"{} has invited you to a JEDI Cloud Workspace".format(owner_name),
body,
)
@bp.route("/workspaces/<workspace_id>/members/new", methods=["POST"])
def create_member(workspace_id):
workspace = Workspaces.get(g.current_user, workspace_id)
@@ -226,6 +237,13 @@ def create_member(workspace_id):
if form.validate():
try:
new_member = Workspaces.create_member(g.current_user, workspace, form.data)
invite = Invitations.create(
new_member.workspace_role, g.current_user, new_member.user
)
send_invite_email(
g.current_user.full_name, invite.token, new_member.user.email
)
return redirect(
url_for(
"workspaces.workspace_members",
@@ -318,3 +336,14 @@ def update_member(workspace_id, member_id):
workspace=workspace,
member=member,
)
@bp.route("/workspaces/invitation/<token>", methods=["GET"])
def accept_invitation(token):
# TODO: check that the current_user DOD ID matches the user associated with
# the invitation
invite = Invitations.accept(token)
return redirect(
url_for("workspaces.show_workspace", workspace_id=invite.workspace.id)
)