Update atst to atat
This commit is contained in:
7
atat/models/mixins/__init__.py
Normal file
7
atat/models/mixins/__init__.py
Normal 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
|
121
atat/models/mixins/auditable.py
Normal file
121
atat/models/mixins/auditable.py
Normal 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,
|
||||
)
|
5
atat/models/mixins/claimable.py
Normal file
5
atat/models/mixins/claimable.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy import Column, TIMESTAMP
|
||||
|
||||
|
||||
class ClaimableMixin(object):
|
||||
claimed_until = Column(TIMESTAMP(timezone=True))
|
6
atat/models/mixins/deletable.py
Normal file
6
atat/models/mixins/deletable.py
Normal 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())
|
139
atat/models/mixins/invites.py
Normal file
139
atat/models/mixins/invites.py
Normal 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
|
6
atat/models/mixins/permissions.py
Normal file
6
atat/models/mixins/permissions.py
Normal 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
|
||||
]
|
158
atat/models/mixins/state_machines.py
Normal file
158
atat/models/mixins/state_machines.py
Normal 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)
|
13
atat/models/mixins/timestamps.py
Normal file
13
atat/models/mixins/timestamps.py
Normal 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(),
|
||||
)
|
Reference in New Issue
Block a user