Merge branch 'master' of github-DDS:dod-ccpo/atst into delete-user-update

This commit is contained in:
rachel-dtr
2019-04-04 14:05:32 -04:00
42 changed files with 708 additions and 129 deletions

View File

@@ -29,11 +29,16 @@ from atst.utils.form_cache import FormCache
from atst.utils.json import CustomJSONEncoder
from atst.queue import queue
from logging.config import dictConfig
from atst.utils.logging import JsonFormatter, RequestContextFilter
ENV = os.getenv("FLASK_ENV", "dev")
def make_app(config):
if ENV == "prod" or config.get("LOG_JSON"):
apply_json_logger()
parent_dir = Path().parent
@@ -143,6 +148,7 @@ def map_config(config):
"RQ_QUEUES": [config["default"]["RQ_QUEUES"]],
"DISABLE_CRL_CHECK": config.getboolean("default", "DISABLE_CRL_CHECK"),
"CRL_FAIL_OPEN": config.getboolean("default", "CRL_FAIL_OPEN"),
"LOG_JSON": config.getboolean("default", "LOG_JSON"),
}
@@ -228,3 +234,22 @@ def make_mailer(app):
)
sender = app.config.get("MAIL_SENDER")
app.mailer = mailer.Mailer(mailer_connection, sender)
def apply_json_logger():
dictConfig(
{
"version": 1,
"formatters": {"default": {"()": lambda *a, **k: JsonFormatter()}},
"filters": {"requests": {"()": lambda *a, **k: RequestContextFilter()}},
"handlers": {
"wsgi": {
"class": "logging.StreamHandler",
"stream": "ext://flask.logging.wsgi_errors_stream",
"formatter": "default",
"filters": ["requests"],
}
},
"root": {"level": "INFO", "handlers": ["wsgi"]},
}
)

View File

@@ -22,6 +22,8 @@ def apply_authentication(app):
user = get_current_user()
if user:
g.current_user = user
g.last_login = get_last_login()
if should_redirect_to_user_profile(request, user):
return redirect(url_for("users.user", next=request.path))
elif not _unprotected_route(request):
@@ -50,9 +52,14 @@ def get_current_user():
return False
def get_last_login():
return session.get("user_id") and session.get("last_login")
def logout():
if session.get("user_id"): # pragma: no branch
del session["user_id"]
del session["last_login"]
def _unprotected_route(request):

View File

@@ -2,6 +2,7 @@ import sys
import os
import re
import hashlib
import logging
from flask import current_app as app
from datetime import datetime
from OpenSSL import crypto, SSL
@@ -30,9 +31,9 @@ class CRLInterface:
def __init__(self, *args, logger=None, **kwargs):
self.logger = logger
def _log_info(self, message):
def _log(self, message, level=logging.INFO):
if self.logger:
self.logger.info(message)
self.logger.log(level, message, extras={"tags": ["authorization", "crl"]})
def crl_check(self, cert):
raise NotImplementedError()
@@ -50,7 +51,7 @@ class NoOpCRLCache(CRLInterface):
def crl_check(self, cert):
cn = self._get_cn(cert)
self._log_info(
self._log(
"Did not perform CRL validation for certificate with Common Name '{}'".format(
cn
)
@@ -111,11 +112,14 @@ class CRLCache(CRLInterface):
try:
return crypto.load_crl(crypto.FILETYPE_ASN1, crl_file.read())
except crypto.Error:
self._log_info("Could not load CRL at location {}".format(crl_location))
self._log(
"Could not load CRL at location {}".format(crl_location),
level=logging.WARNING,
)
def _build_store(self, issuer):
store = self.store_class()
self._log_info("STORE ID: {}. Building store.".format(id(store)))
self._log("STORE ID: {}. Building store.".format(id(store)))
store.set_flags(crypto.X509StoreFlags.CRL_CHECK)
crl_info = self.crl_cache.get(issuer.der(), {})
issuer_name = get_common_name(issuer)
@@ -132,7 +136,7 @@ class CRLCache(CRLInterface):
crl = self._load_crl(crl_info["location"])
store.add_crl(crl)
self._log_info(
self._log(
"STORE ID: {}. Adding CRL with issuer Common Name {}".format(
id(store), issuer_name
)
@@ -158,7 +162,7 @@ class CRLCache(CRLInterface):
def _add_certificate_chain_to_store(self, store, issuer):
ca = self.certificate_authorities.get(issuer.der())
store.add_cert(ca)
self._log_info(
self._log(
"STORE ID: {}. Adding CA with subject {}".format(
id(store), ca.get_subject()
)
@@ -182,10 +186,11 @@ class CRLCache(CRLInterface):
except crypto.X509StoreContextError as err:
if err.args[0][0] == CRL_EXPIRED_ERROR_CODE:
if app.config.get("CRL_FAIL_OPEN"):
self._log_info(
self._log(
"Encountered expired CRL for certificate with CN {} and issuer CN {}, failing open.".format(
parsed.get_subject().CN, parsed.get_issuer().CN
)
),
level=logging.WARNING,
)
return True
else:

View File

@@ -1,6 +1,7 @@
from atst.utils import first_or_none
from atst.models.permissions import Permissions
from atst.domain.exceptions import UnauthorizedError
from atst.models.portfolio_role import Status as PortfolioRoleStatus
class Authorization(object):
@@ -9,7 +10,7 @@ class Authorization(object):
port_role = first_or_none(
lambda pr: pr.portfolio == portfolio, user.portfolio_roles
)
if port_role:
if port_role and port_role.status is not PortfolioRoleStatus.DISABLED:
return permission in port_role.permissions
else:
return False

View File

@@ -45,17 +45,19 @@ def user_can_access_decorator(permission, message=None, override=None):
try:
check_access(permission, message, override, *args, **kwargs)
app.logger.info(
"[access] User {} accessed {} {}".format(
"User {} accessed {} {}".format(
g.current_user.id, request.method, request.path
)
),
extra={"tags": ["access", "success"]},
)
return f(*args, **kwargs)
except UnauthorizedError as err:
app.logger.warning(
"[access] User {} denied access {} {}".format(
"User {} denied access {} {}".format(
g.current_user.id, request.method, request.path
)
),
extra={"tags": ["access", "failure"]},
)
raise (err)

View File

@@ -121,6 +121,15 @@ class PortfolioRoles(object):
)
return PermissionSets.get_many(perms_set_names)
@classmethod
def disable(cls, portfolio_role):
portfolio_role.status = PortfolioRoleStatus.DISABLED
db.session.add(portfolio_role)
db.session.commit()
return portfolio_role
@classmethod
def update(cls, portfolio_role, set_names):
new_permission_sets = PortfolioRoles._permission_sets_for_names(set_names)

View File

@@ -1,5 +1,6 @@
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.exc import IntegrityError
from datetime import datetime
from atst.database import db
from atst.models import User
@@ -82,6 +83,12 @@ class Users(object):
return user
@classmethod
def update_last_login(cls, user):
user.last_login = datetime.now()
db.session.add(user)
db.session.commit()
@classmethod
def finalize(cls, user):
user.provisional = False

View File

@@ -29,16 +29,14 @@ USER_FIELDS = {
translate("forms.edit_user.service_branch_label"), choices=SERVICE_BRANCHES
),
"citizenship": RadioField(
description=translate("forms.edit_user.citizenship_description"),
choices=[
("United States", "United States"),
("Foreign National", "Foreign National"),
("Other", "Other"),
],
]
),
"designation": RadioField(
translate("forms.edit_user.designation_label"),
description=translate("forms.edit_user.designation_description"),
choices=[
("military", "Military"),
("civilian", "Civilian"),

View File

@@ -1,6 +1,6 @@
from wtforms.fields import StringField, FormField, FieldList
from wtforms.fields.html5 import EmailField, TelField
from wtforms.validators import Required, Email, Length, Optional
from wtforms.fields import StringField, FormField, FieldList, HiddenField
from atst.domain.permission_sets import PermissionSets
from .forms import BaseForm
@@ -11,32 +11,33 @@ from atst.utils.localization import translate
class PermissionsForm(BaseForm):
member = StringField()
user_id = HiddenField()
perms_app_mgmt = SelectField(
None,
choices=[
(PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT, "View Only"),
(PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT, "Edit Access"),
(PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT, "View only"),
(PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT, "Edit access"),
],
)
perms_funding = SelectField(
None,
choices=[
(PermissionSets.VIEW_PORTFOLIO_FUNDING, "View Only"),
(PermissionSets.EDIT_PORTFOLIO_FUNDING, "Edit Access"),
(PermissionSets.VIEW_PORTFOLIO_FUNDING, "View only"),
(PermissionSets.EDIT_PORTFOLIO_FUNDING, "Edit access"),
],
)
perms_reporting = SelectField(
None,
choices=[
(PermissionSets.VIEW_PORTFOLIO_REPORTS, "View Only"),
(PermissionSets.EDIT_PORTFOLIO_REPORTS, "Edit Access"),
(PermissionSets.VIEW_PORTFOLIO_REPORTS, "View only"),
(PermissionSets.EDIT_PORTFOLIO_REPORTS, "Edit access"),
],
)
perms_portfolio_mgmt = SelectField(
None,
choices=[
(PermissionSets.VIEW_PORTFOLIO_ADMIN, "View Only"),
(PermissionSets.EDIT_PORTFOLIO_ADMIN, "Edit Access"),
(PermissionSets.VIEW_PORTFOLIO_ADMIN, "View only"),
(PermissionSets.EDIT_PORTFOLIO_ADMIN, "Edit access"),
],
)

View File

@@ -1,4 +1,4 @@
from sqlalchemy import String, ForeignKey, Column, Date, Boolean, Table
from sqlalchemy import String, ForeignKey, Column, Date, Boolean, Table, TIMESTAMP
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID
@@ -36,6 +36,7 @@ class User(
citizenship = Column(String)
designation = Column(String)
date_latest_training = Column(Date)
last_login = Column(TIMESTAMP(timezone=True), nullable=True)
provisional = Column(Boolean)

View File

@@ -122,6 +122,12 @@ def redirect_after_login_url():
return url_for("atst.home")
def current_user_setup(user):
session["user_id"] = user.id
session["last_login"] = user.last_login
Users.update_last_login(user)
@bp.route("/login-redirect")
def login_redirect():
auth_context = _make_authentication_context()
@@ -131,8 +137,7 @@ def login_redirect():
if user.provisional:
Users.finalize(user)
session["user_id"] = user.id
current_user_setup(user)
return redirect(redirect_after_login_url())

View File

@@ -1,7 +1,6 @@
from flask import (
Blueprint,
request,
session,
redirect,
render_template,
url_for,
@@ -9,7 +8,7 @@ from flask import (
)
import pendulum
from . import redirect_after_login_url
from . import redirect_after_login_url, current_user_setup
from atst.domain.users import Users
from atst.domain.permission_sets import PermissionSets
from atst.queue import queue
@@ -124,8 +123,7 @@ def login_dev():
user_data,
),
)
session["user_id"] = user.id
current_user_setup(user)
return redirect(redirect_after_login_url())

View File

@@ -15,7 +15,7 @@ from atst.utils.flash import formatted_flash as flash
def log_error(e):
error_message = e.message if hasattr(e, "message") else str(e)
current_app.logger.error(error_message)
current_app.logger.exception(error_message)
def handle_error(e, message="Not Found", code=404):

View File

@@ -5,6 +5,7 @@ from flask import render_template, request as http_request, g, redirect, url_for
from . import portfolios_bp
from atst.domain.reports import Reports
from atst.domain.portfolios import Portfolios
from atst.domain.portfolio_roles import PortfolioRoles
from atst.domain.audit_log import AuditLog
from atst.domain.common import Paginator
from atst.forms.portfolio import PortfolioForm
@@ -12,6 +13,8 @@ import atst.forms.portfolio_member as member_forms
from atst.models.permissions import Permissions
from atst.domain.permission_sets import PermissionSets
from atst.domain.authz.decorator import user_can_access_decorator as user_can
from atst.utils.flash import formatted_flash as flash
from atst.domain.exceptions import UnauthorizedError
@portfolios_bp.route("/portfolios")
@@ -34,6 +37,7 @@ def permission_str(member, edit_perm_set, view_perm_set):
def serialize_member_form_data(member):
return {
"member": member.user.full_name,
"user_id": member.user_id,
"perms_app_mgmt": permission_str(
member,
PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT,
@@ -86,6 +90,33 @@ def portfolio_admin(portfolio_id):
return render_admin_page(portfolio)
@portfolios_bp.route("/portfolios/<portfolio_id>/admin", methods=["POST"])
@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="view portfolio admin page")
def edit_portfolio_members(portfolio_id):
portfolio = Portfolios.get_for_update(portfolio_id)
member_perms_form = member_forms.MembersPermissionsForm(http_request.form)
if member_perms_form.validate():
for subform in member_perms_form.members_permissions:
new_perm_set = subform.data["permission_sets"]
user_id = subform.user_id.data
portfolio_role = PortfolioRoles.get(portfolio.id, user_id)
PortfolioRoles.update(portfolio_role, new_perm_set)
flash("update_portfolio_members", portfolio=portfolio)
return redirect(
url_for(
"portfolios.portfolio_admin",
portfolio_id=portfolio.id,
fragment="portfolio-members",
_anchor="portfolio-members",
)
)
else:
return render_admin_page(portfolio)
@portfolios_bp.route("/portfolios/<portfolio_id>/edit", methods=["POST"])
@user_can(Permissions.EDIT_PORTFOLIO_NAME, message="edit portfolio")
def edit_portfolio(portfolio_id):
@@ -143,3 +174,28 @@ def portfolio_reports(portfolio_id):
expiration_date=expiration_date,
remaining_days=remaining_days,
)
@portfolios_bp.route(
"/portfolios/<portfolio_id>/members/<user_id>/delete", methods=["POST"]
)
@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="update portfolio members")
def remove_member(portfolio_id, user_id):
if str(g.current_user.id) == user_id:
raise UnauthorizedError(
g.current_user, "you cant remove yourself from the portfolio"
)
portfolio_role = PortfolioRoles.get(portfolio_id=portfolio_id, user_id=user_id)
PortfolioRoles.disable(portfolio_role=portfolio_role)
flash("portfolio_member_removed", member_name=portfolio_role.user.full_name)
return redirect(
url_for(
"portfolios.portfolio_admin",
portfolio_id=portfolio_id,
_anchor="portfolio-members",
fragment="portfolio-members",
)
)

View File

@@ -231,6 +231,7 @@ def task_order_invitations(portfolio_id, task_order_id):
portfolio=portfolio,
task_order=task_order,
form=form,
user=g.current_user,
)
else:
raise NotFoundError("task_order")

View File

@@ -52,7 +52,7 @@ class Invitation:
inviter,
member,
email,
subject="{} has invited you to a JEDI Cloud Portfolio",
subject="{} has invited you to a JEDI cloud portfolio",
email_template="emails/invitation.txt",
):
self.inviter = inviter

View File

@@ -21,6 +21,13 @@ MESSAGES = {
""",
"category": "success",
},
"update_portfolio_members": {
"title_template": "Success!",
"message_template": """
<p>You have successfully updated access permissions for members of {{ portfolio.name }}.</p>
""",
"category": "success",
},
"new_portfolio_member": {
"title_template": "Success!",
"message_template": """
@@ -131,6 +138,11 @@ MESSAGES = {
""",
"category": "error",
},
"portfolio_member_removed": {
"title_template": "Portfolio Member Removed",
"message_template": "You have successfully removed {{ member_name }} from the portfolio.",
"category": "success",
},
}

47
atst/utils/logging.py Normal file
View File

@@ -0,0 +1,47 @@
import datetime
import json
import logging
from flask import g, request, has_request_context
class RequestContextFilter(logging.Filter):
def filter(self, record):
if has_request_context():
if getattr(g, "current_user", None):
record.user_id = str(g.current_user.id)
if request.environ.get("HTTP_X_REQUEST_ID"):
record.request_id = request.environ.get("HTTP_X_REQUEST_ID")
return True
def epoch_to_iso8601(ts):
dt = datetime.datetime.utcfromtimestamp(ts)
return dt.replace(tzinfo=datetime.timezone.utc).isoformat()
class JsonFormatter(logging.Formatter):
_DEFAULT_RECORD_FIELDS = [
("timestamp", lambda r: epoch_to_iso8601(r.created)),
("version", lambda r: 1),
("request_id", lambda r: r.__dict__.get("request_id")),
("user_id", lambda r: r.__dict__.get("user_id")),
("severity", lambda r: r.levelname),
("tags", lambda r: r.__dict__.get("tags")),
("message", lambda r: r.msg),
]
def format(self, record):
message_dict = {}
for field, func in self._DEFAULT_RECORD_FIELDS:
message_dict[field] = func(record)
if record.__dict__.get("exc_info") is not None:
message_dict["details"] = {
"backtrace": self.formatException(record.exc_info),
"exception": str(record.exc_info[1]),
}
return json.dumps(message_dict)