Update atst to atat

This commit is contained in:
leigh-mil
2020-02-28 16:01:45 -05:00
parent 6eb48239cf
commit c2814416fb
215 changed files with 735 additions and 746 deletions

View File

@@ -0,0 +1,7 @@
from .timestamps import TimestampsMixin
from .auditable import AuditableMixin
from .permissions import PermissionsMixin
from .deletable import DeletableMixin
from .invites import InvitesMixin
from .state_machines import FSMMixin
from .claimable import ClaimableMixin

View File

@@ -0,0 +1,121 @@
from sqlalchemy import event, inspect
from flask import g, current_app as app
from atat.models.audit_event import AuditEvent
from atat.utils import camel_to_snake, getattr_path
ACTION_CREATE = "create"
ACTION_UPDATE = "update"
ACTION_DELETE = "delete"
class AuditableMixin(object):
@staticmethod
def create_audit_event(connection, resource, action, changed_state=None):
user_id = getattr_path(g, "current_user.id")
if changed_state is None:
changed_state = resource.history if action == ACTION_UPDATE else None
log_data = {
"user_id": user_id,
"portfolio_id": resource.portfolio_id,
"application_id": resource.application_id,
"resource_type": resource.resource_type,
"resource_id": resource.id,
"display_name": resource.displayname,
"action": action,
"changed_state": changed_state,
"event_details": resource.event_details,
}
app.logger.info(
"Audit Event {}".format(action),
extra={
"audit_event": {key: str(value) for key, value in log_data.items()},
"tags": ["audit_event", action],
},
)
if app.config.get("USE_AUDIT_LOG", False):
audit_event = AuditEvent(**log_data)
audit_event.save(connection)
@classmethod
def __declare_last__(cls):
event.listen(cls, "after_insert", cls.audit_insert)
event.listen(cls, "after_delete", cls.audit_delete)
event.listen(cls, "after_update", cls.audit_update)
@staticmethod
def audit_insert(mapper, connection, target):
"""Listen for the `after_insert` event and create an AuditLog entry"""
target.create_audit_event(connection, target, ACTION_CREATE)
@staticmethod
def audit_delete(mapper, connection, target):
"""Listen for the `after_delete` event and create an AuditLog entry"""
target.create_audit_event(connection, target, ACTION_DELETE)
@staticmethod
def audit_update(mapper, connection, target):
if AuditableMixin.get_changes(target):
target.create_audit_event(connection, target, ACTION_UPDATE)
def get_changes(self):
"""
This function returns a dictionary of the form {item: [from_value, to_value]},
where 'item' is the attribute on the target that has been updated,
'from_value' is the value of the attribute before it was updated,
and 'to_value' is the current value of the attribute.
There may be more than one item in the dictionary, but that is not expected.
"""
previous_state = {}
attrs = inspect(self).mapper.column_attrs
for attr in attrs:
history = getattr(inspect(self).attrs, attr.key).history
if history.has_changes():
deleted = history.deleted.pop() if history.deleted else None
added = history.added.pop() if history.added else None
previous_state[attr.key] = [deleted, added]
return previous_state
@property
def history(self):
return None
@property
def event_details(self):
return None
@property
def resource_type(self):
return camel_to_snake(type(self).__name__)
@property
def portfolio_id(self):
raise NotImplementedError()
@property
def application_id(self):
raise NotImplementedError()
@property
def displayname(self):
return None
def record_permission_sets_updates(instance_state, permission_sets, initiator):
old_perm_sets = instance_state.attrs.get("permission_sets").value
if instance_state.persistent and old_perm_sets != permission_sets:
connection = instance_state.session.connection()
old_state = [p.name for p in old_perm_sets]
new_state = [p.name for p in permission_sets]
changed_state = {"permission_sets": (old_state, new_state)}
instance_state.object.create_audit_event(
connection,
instance_state.object,
ACTION_UPDATE,
changed_state=changed_state,
)

View File

@@ -0,0 +1,5 @@
from sqlalchemy import Column, TIMESTAMP
class ClaimableMixin(object):
claimed_until = Column(TIMESTAMP(timezone=True))

View File

@@ -0,0 +1,6 @@
from sqlalchemy import Column, Boolean
from sqlalchemy.sql import expression
class DeletableMixin(object):
deleted = Column(Boolean, nullable=False, server_default=expression.false())

View File

@@ -0,0 +1,139 @@
import pendulum
from enum import Enum
import secrets
from sqlalchemy import Column, ForeignKey, Enum as SQLAEnum, TIMESTAMP, String
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from atat.models import types
class Status(Enum):
ACCEPTED = "accepted"
REVOKED = "revoked"
PENDING = "pending"
REJECTED_WRONG_USER = "rejected_wrong_user"
REJECTED_EXPIRED = "rejected_expired"
class InvitesMixin(object):
id = types.Id()
@declared_attr
def user_id(cls):
return Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
@declared_attr
def user(cls):
return relationship("User", foreign_keys=[cls.user_id])
@declared_attr
def inviter_id(cls):
return Column(
UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=False
)
@declared_attr
def inviter(cls):
return relationship("User", foreign_keys=[cls.inviter_id])
status = Column(
SQLAEnum(Status, native_enum=False, default=Status.PENDING, nullable=False)
)
expiration_time = Column(TIMESTAMP(timezone=True), nullable=False)
token = Column(
String, index=True, default=lambda: secrets.token_urlsafe(), nullable=False
)
email = Column(String, nullable=False)
dod_id = Column(String, nullable=False)
first_name = Column(String, nullable=False)
last_name = Column(String, nullable=False)
phone_number = Column(String)
phone_ext = Column(String)
def __repr__(self):
role_id = self.role.id if self.role else None
return "<{}(user='{}', role='{}', id='{}', email='{}')>".format(
self.__class__.__name__, self.user_id, role_id, self.id, self.email
)
@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 in [Status.REJECTED_WRONG_USER, Status.REJECTED_EXPIRED]
@property
def is_rejected_expired(self):
return self.status == Status.REJECTED_EXPIRED
@property
def is_rejected_wrong_user(self):
return self.status == Status.REJECTED_WRONG_USER
@property
def is_expired(self):
return (
pendulum.now(tz=self.expiration_time.tzinfo) > self.expiration_time
and not self.status == Status.ACCEPTED
)
@property
def is_inactive(self):
return self.is_expired or self.status in [
Status.REJECTED_WRONG_USER,
Status.REJECTED_EXPIRED,
Status.REVOKED,
]
@property
def user_name(self):
return "{} {}".format(self.first_name, self.last_name)
@property
def is_revokable(self):
return self.is_pending and not self.is_expired
@property
def can_resend(self):
return self.is_pending or self.is_expired
@property
def user_dod_id(self):
return self.user.dod_id if self.user is not None else None
@property
def event_details(self):
"""Overrides the same property in AuditableMixin.
Provides the event details for an invite that are required for the audit log
"""
return {"email": self.email, "dod_id": self.user_dod_id}
@property
def history(self):
"""Overrides the same property in AuditableMixin
Determines whether or not invite status has been updated
"""
changes = self.get_changes()
change_set = {}
if "status" in changes:
change_set["status"] = [s.name for s in changes["status"]]
return change_set

View File

@@ -0,0 +1,6 @@
class PermissionsMixin(object):
@property
def permissions(self):
return [
perm for permset in self.permission_sets for perm in permset.permissions
]

View File

@@ -0,0 +1,158 @@
from enum import Enum
from flask import current_app as app
class StageStates(Enum):
CREATED = "created"
IN_PROGRESS = "in progress"
FAILED = "failed"
class AzureStages(Enum):
TENANT = "tenant"
BILLING_PROFILE_CREATION = "billing profile creation"
BILLING_PROFILE_VERIFICATION = "billing profile verification"
BILLING_PROFILE_TENANT_ACCESS = "billing profile tenant access"
TASK_ORDER_BILLING_CREATION = "task order billing creation"
TASK_ORDER_BILLING_VERIFICATION = "task order billing verification"
BILLING_INSTRUCTION = "billing instruction"
PRODUCT_PURCHASE = "purchase aad premium product"
PRODUCT_PURCHASE_VERIFICATION = "purchase aad premium product verification"
TENANT_PRINCIPAL_APP = "tenant principal application"
TENANT_PRINCIPAL = "tenant principal"
TENANT_PRINCIPAL_CREDENTIAL = "tenant principal credential"
ADMIN_ROLE_DEFINITION = "admin role definition"
PRINCIPAL_ADMIN_ROLE = "tenant principal admin"
INITIAL_MGMT_GROUP = "initial management group"
INITIAL_MGMT_GROUP_VERIFICATION = "initial management group verification"
TENANT_ADMIN_OWNERSHIP = "tenant admin ownership"
TENANT_PRINCIPAL_OWNERSHIP = "tenant principial ownership"
BILLING_OWNER = "billing owner"
def _build_csp_states(csp_stages):
states = {
"UNSTARTED": "unstarted",
"STARTING": "starting",
"STARTED": "started",
"COMPLETED": "completed",
"FAILED": "failed",
}
for csp_stage in csp_stages:
for state in StageStates:
states[csp_stage.name + "_" + state.name] = (
csp_stage.value + " " + state.value
)
return states
FSMStates = Enum("FSMStates", _build_csp_states(AzureStages))
compose_state = lambda csp_stage, state: getattr(
FSMStates, "_".join([csp_stage.name, state.name])
)
def _build_transitions(csp_stages):
transitions = []
states = []
for stage_i, csp_stage in enumerate(csp_stages):
# the last CREATED stage has a transition to COMPLETED
if stage_i == len(csp_stages) - 1:
transitions.append(
dict(
trigger="complete",
source=compose_state(
list(csp_stages)[stage_i], StageStates.CREATED
),
dest=FSMStates.COMPLETED,
)
)
for state in StageStates:
states.append(
dict(
name=compose_state(csp_stage, state),
tags=[csp_stage.name, state.name],
)
)
if state == StageStates.CREATED:
if stage_i > 0:
src = compose_state(
list(csp_stages)[stage_i - 1], StageStates.CREATED
)
else:
src = FSMStates.STARTED
transitions.append(
dict(
trigger="create_" + csp_stage.name.lower(),
source=src,
dest=compose_state(csp_stage, StageStates.IN_PROGRESS),
after="after_in_progress_callback",
)
)
if state == StageStates.IN_PROGRESS:
transitions.append(
dict(
trigger="finish_" + csp_stage.name.lower(),
source=compose_state(csp_stage, state),
dest=compose_state(csp_stage, StageStates.CREATED),
conditions=["is_csp_data_valid"],
)
)
if state == StageStates.FAILED:
transitions.append(
dict(
trigger="fail_" + csp_stage.name.lower(),
source=compose_state(csp_stage, StageStates.IN_PROGRESS),
dest=compose_state(csp_stage, StageStates.FAILED),
)
)
transitions.append(
dict(
trigger="resume_progress_" + csp_stage.name.lower(),
source=compose_state(csp_stage, StageStates.FAILED),
dest=compose_state(csp_stage, StageStates.IN_PROGRESS),
conditions=["is_ready_resume_progress"],
)
)
return states, transitions
class FSMMixin:
system_states = [
{"name": FSMStates.UNSTARTED.name, "tags": ["system"]},
{"name": FSMStates.STARTING.name, "tags": ["system"]},
{"name": FSMStates.STARTED.name, "tags": ["system"]},
{"name": FSMStates.FAILED.name, "tags": ["system"]},
{"name": FSMStates.COMPLETED.name, "tags": ["system"]},
]
system_transitions = [
{"trigger": "init", "source": FSMStates.UNSTARTED, "dest": FSMStates.STARTING},
{"trigger": "start", "source": FSMStates.STARTING, "dest": FSMStates.STARTED},
{"trigger": "reset", "source": "*", "dest": FSMStates.UNSTARTED},
{"trigger": "fail", "source": "*", "dest": FSMStates.FAILED,},
]
def fail_stage(self, stage):
fail_trigger = f"fail_{stage}"
if fail_trigger in self.machine.get_triggers(self.current_state.name):
self.trigger(fail_trigger)
app.logger.info(
f"calling fail trigger '{fail_trigger}' for '{self.__repr__()}'"
)
else:
app.logger.info(
f"could not locate fail trigger '{fail_trigger}' for '{self.__repr__()}'"
)
def finish_stage(self, stage):
finish_trigger = f"finish_{stage}"
if finish_trigger in self.machine.get_triggers(self.current_state.name):
app.logger.info(
f"calling finish trigger '{finish_trigger}' for '{self.__repr__()}'"
)
self.trigger(finish_trigger)

View File

@@ -0,0 +1,13 @@
from sqlalchemy import Column, func, TIMESTAMP
class TimestampsMixin(object):
time_created = Column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
time_updated = Column(
TIMESTAMP(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.current_timestamp(),
)