Merge branch 'master' of github-DDS:dod-ccpo/atst into delete-user-update
This commit is contained in:
25
atst/app.py
25
atst/app.py
@@ -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"]},
|
||||
}
|
||||
)
|
||||
|
@@ -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):
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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"),
|
||||
|
@@ -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"),
|
||||
],
|
||||
)
|
||||
|
||||
|
@@ -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)
|
||||
|
||||
|
@@ -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())
|
||||
|
||||
|
||||
|
@@ -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())
|
||||
|
||||
|
||||
|
@@ -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):
|
||||
|
@@ -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",
|
||||
)
|
||||
)
|
||||
|
@@ -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")
|
||||
|
@@ -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
|
||||
|
@@ -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
47
atst/utils/logging.py
Normal 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)
|
Reference in New Issue
Block a user