"""Workflow models: definitions, scopes, instances, and enrollment requests."""
from __future__ import annotations
import uuid
from typing import Any, cast
from django.db import models
from django.db.models import JSONField
from devices.models import DeviceModel
from pki.models import CaModel
from pki.models.domain import DomainModel
# -------------------------------
# Workflow definitions + scoping
# -------------------------------
[docs]
class State(models.TextChoices):
"""Workflow and enrollment states."""
[docs]
RUNNING = 'Running', 'Running'
[docs]
AWAITING = 'AwaitingApproval', 'AwaitingApproval'
[docs]
APPROVED = 'Approved', 'Approved'
[docs]
PASSED = 'Passed', 'Passed'
[docs]
FINALIZED = 'Finalized', 'Finalized'
[docs]
REJECTED = 'Rejected', 'Rejected'
[docs]
FAILED = 'Failed', 'Failed'
[docs]
ABORTED = 'Aborted', 'Aborted'
[docs]
StatusBadge = tuple[str, str]
"""Tuple of (label, bootstrap_badge_class)."""
[docs]
BADGE_MAP: dict[str, StatusBadge] = {
State.RUNNING: ('Running', 'bg-primary'),
State.AWAITING: ('Awaiting approval', 'bg-warning text-dark'),
State.APPROVED: ('Approved', 'bg-success'),
State.REJECTED: ('Rejected', 'bg-danger'),
State.FAILED: ('Failed', 'bg-danger'),
State.ABORTED: ('Aborted', 'bg-dark'),
State.PASSED: ('Passed', 'bg-success'),
State.FINALIZED: ('Finalized', 'bg-secondary'),
}
[docs]
def get_status_badge(raw: str | State | None) -> StatusBadge:
"""Return a badge (label, CSS class) for a workflow/enrollment state."""
if raw is None:
return 'Unknown', 'bg-light text-muted'
key = str(raw)
# Direct match (covers State enum members)
if key in BADGE_MAP:
return BADGE_MAP[key]
# Normalized string match (defensive)
norm = key.strip().lower()
for state_key, badge in BADGE_MAP.items():
if state_key.lower() == norm:
return badge
# Fallback: unknown but not None
return key, 'bg-secondary text-light'
[docs]
class WorkflowDefinition(models.Model):
"""Blueprint of a workflow: event, steps, transitions."""
[docs]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
[docs]
name = models.CharField(max_length=100, unique=True)
[docs]
version = models.PositiveIntegerField(default=1)
[docs]
published = models.BooleanField(default=False)
[docs]
definition = JSONField() # {"events":[...], "steps":[...]}
[docs]
created_at = models.DateTimeField(auto_now_add=True)
[docs]
updated_at = models.DateTimeField(auto_now=True)
def __str__(self) -> str:
"""Return human-readable representation with version."""
return f'{self.name} v{self.version}'
[docs]
class WorkflowScope(models.Model):
"""Assign a workflow to CAs, domains, or devices (NULL = any)."""
[docs]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
[docs]
workflow = models.ForeignKey(WorkflowDefinition, on_delete=models.CASCADE, related_name='scopes')
[docs]
ca_id = models.IntegerField(null=True, blank=True)
[docs]
domain_id = models.IntegerField(null=True, blank=True)
[docs]
device_id = models.IntegerField(null=True, blank=True)
def __str__(self) -> str:
"""Return human-readable representation."""
parts: list[str] = []
if self.ca_id is not None:
parts.append(f'CA={self.ca_id}')
if self.domain_id is not None:
parts.append(f'Domain={self.domain_id}')
if self.device_id is not None:
parts.append(f'Device={self.device_id}')
suffix = ', '.join(parts) if parts else 'any'
return f'{self.workflow.name} [{suffix}]'
[docs]
class DeviceRequest(models.Model):
"""Represents a device lifecycle request (creation, deletion, onboarding).
Attributes:
id: UUID primary key.
device: Target device.
domain: Domain at the time of event (may be null).
ca: CA for domain (if applicable).
action: Device action type ("created", "onboarded", "deleted").
payload: Raw event payload from the handler.
aggregated_state: Aggregate state over all workflow instances.
finalized: True if all workflow instances have reached terminal states.
created_at: Request creation timestamp.
updated_at: Last update timestamp.
"""
[docs]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
[docs]
device = models.ForeignKey(
DeviceModel,
on_delete=models.CASCADE,
related_name='device_requests',
)
[docs]
domain = models.ForeignKey(
DomainModel,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='device_requests',
)
[docs]
ca = models.ForeignKey(
CaModel,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='device_requests',
)
[docs]
action = models.CharField(max_length=32) # "created", "onboarded", "deleted"
[docs]
payload = models.JSONField(default=dict, blank=True)
[docs]
aggregated_state = models.CharField(
max_length=32,
choices=State.choices,
default=State.AWAITING,
)
[docs]
finalized = models.BooleanField(default=False)
[docs]
created_at = models.DateTimeField(auto_now_add=True)
[docs]
updated_at = models.DateTimeField(auto_now=True)
def __str__(self) -> str:
"""Return a human-readable representation of the device request."""
device_id = self.device_id if self.device_id is not None else 'None'
return f'DeviceReq#{self.pk} {self.aggregated_state} action={self.action} device={device_id}'
[docs]
def recompute_and_save(self) -> None:
"""Recompute the aggregate final state from instances."""
instances = self.instances.all()
if not instances.exists():
self.aggregated_state = State.AWAITING
self.finalized = False
else:
states = {inst.state for inst in instances}
if any(s == State.FAILED for s in states):
self.aggregated_state = State.FAILED
elif all(s in {State.FINALIZED, State.ABORTED} for s in states):
self.aggregated_state = State.FINALIZED
elif any(s == State.AWAITING for s in states):
self.aggregated_state = State.AWAITING
else:
self.aggregated_state = State.RUNNING
self.finalized = all(inst.finalized for inst in instances)
self.save(update_fields=['aggregated_state', 'finalized', 'updated_at'])
@property
[docs]
def badge_class(self) -> str:
"""Return the CSS class for the aggregated state badge."""
return get_status_badge(self.aggregated_state)[1]
# -------------------------------------
# EnrollmentRequest (EST fan-out parent)
# -------------------------------------
[docs]
class EnrollmentRequest(models.Model):
"""A single logical certificate enrollment attempt (EST simpleenroll fan-out parent).
- Aggregates all child WorkflowInstances that must approve/reject this attempt.
- Identity tuple groups repeated polls for the same CSR until a terminal outcome.
- We keep request-level states distinct from instance-level strings to avoid confusion.
"""
[docs]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
# Identity tuple (NOT unique → allow a new attempt after terminal)
[docs]
protocol = models.CharField(max_length=50)
[docs]
operation = models.CharField(max_length=50)
[docs]
device = models.ForeignKey(DeviceModel, on_delete=models.CASCADE, related_name='device', null=True, blank=True)
[docs]
domain = models.ForeignKey(DomainModel, on_delete=models.CASCADE, related_name='domain', null=True, blank=True)
[docs]
ca = models.ForeignKey(
CaModel,
on_delete=models.CASCADE,
related_name='enrollment_requests',
null=True,
blank=True
)
[docs]
fingerprint = models.CharField(max_length=128) # CSR fingerprint (sha256 hex)
[docs]
template = models.CharField(max_length=100, blank=True, default='')
[docs]
aggregated_state = models.CharField(max_length=32, choices=State.choices, default=State.AWAITING)
[docs]
finalized = models.BooleanField(default=False)
[docs]
created_at = models.DateTimeField(auto_now_add=True)
[docs]
updated_at = models.DateTimeField(auto_now=True)
def __str__(self) -> str:
"""Return human-readable representation."""
return (
f'EnrollReq#{self.pk} {self.aggregated_state} {self.protocol}/{self.operation} fp={self.fingerprint[:8]}…'
)
@property
[docs]
def badge_label(self) -> str:
"""Return the human-readable badge label for the aggregated state."""
label, _ = get_status_badge(self.aggregated_state)
return label
@property
[docs]
def badge_class(self) -> str:
"""Return the CSS class for the aggregated state badge."""
_, css = get_status_badge(self.aggregated_state)
return css
# ---- aggregation helpers ----
[docs]
def recompute_status(self) -> State:
"""Compute aggregate status from child instances.
Returns:
The new aggregated state derived from child instance states.
"""
children_qs = self.instances.all()
if not children_qs.exists():
return State.PASSED
inst_states = set(children_qs.values_list('state', flat=True))
if State.REJECTED in inst_states:
result: State = State.REJECTED
elif State.FAILED in inst_states:
result = State.FAILED
elif State.ABORTED in inst_states:
result = State.ABORTED
elif State.AWAITING in inst_states or State.RUNNING in inst_states:
result = State.AWAITING
elif inst_states.issubset({State.APPROVED, State.PASSED}):
result = State.APPROVED
else:
result = State.AWAITING
return result
[docs]
def is_valid(self) -> bool:
"""Return True if the enrollment request is in a successful terminal state."""
return self.aggregated_state in {State.APPROVED, State.PASSED}
[docs]
def recompute_and_save(self) -> State:
"""Recalculate the aggregated state and persist changes if it changed.
Returns:
The (possibly unchanged) aggregated state.
"""
new_status = self.recompute_status()
if new_status != self.aggregated_state:
self.aggregated_state = new_status
self.save(update_fields=['aggregated_state', 'updated_at'])
return State(self.aggregated_state)
[docs]
def finalize(self, final_status: str | State | None = None) -> None:
"""Finalize this request and all non-finalized child workflow instances.
Args:
final_status: Optional final aggregated state to set for the request.
"""
self.finalized = True
if final_status is None:
self.save(update_fields=['finalized', 'updated_at'])
else:
self.aggregated_state = str(final_status)
self.save(update_fields=['aggregated_state', 'finalized', 'updated_at'])
for inst in self.instances.filter(finalized=False):
inst.finalize()
[docs]
def abort(self) -> None:
"""Abort this request and all non-finalized child workflow instances."""
if self.finalized:
return
self.aggregated_state = State.ABORTED
self.finalized = True
self.save(update_fields=['aggregated_state', 'finalized', 'updated_at'])
for inst in self.instances.filter(finalized=False):
inst.finalize(State.ABORTED)
# -------------------------------
# Workflow instances (children)
# -------------------------------
[docs]
class WorkflowInstance(models.Model):
"""An initialized workflows."""
[docs]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
[docs]
definition = models.ForeignKey(WorkflowDefinition, on_delete=models.CASCADE, related_name='instances')
# link to parent EnrollmentRequest (EST fan-out)
[docs]
enrollment_request = models.ForeignKey(
EnrollmentRequest,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name='instances',
help_text='Parent request for EST fan-out orchestration.',
)
[docs]
device_request = models.ForeignKey(
DeviceRequest,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='instances',
)
[docs]
current_step = models.CharField(max_length=100, help_text='The step-ID we are currently at (e.g. "step-1")')
[docs]
state = models.CharField(max_length=32, choices=State.choices, default=State.RUNNING)
[docs]
payload = JSONField(help_text='Immutable inputs (eg. CSR fingerprint, CA/Domain/Device IDs)')
[docs]
step_contexts = JSONField(
default=dict,
help_text=('Mutable per-step storage: e.g. for Approval, email-sent flag; for Timer, deadline timestamp; etc.'),
)
[docs]
finalized = models.BooleanField(
default=False,
help_text='Once true, this instance will never be re-queued or advanced again.',
)
[docs]
created_at = models.DateTimeField(auto_now_add=True)
[docs]
updated_at = models.DateTimeField(auto_now=True)
def __str__(self) -> str:
"""Return a short identifier containing definition name, PK, and state."""
return f'{self.definition.name}#{self.pk} ({self.state})'
@property
[docs]
def badge_label(self) -> str:
"""Return the human-readable badge label for this instance's state."""
label, _ = get_status_badge(self.state)
return label
@property
[docs]
def badge_class(self) -> str:
"""Return the CSS class for this instance's state badge."""
_, css = get_status_badge(self.state)
return css
[docs]
def finalize(self, state: str | State | None = None) -> None:
"""Mark this instance as fully done and optionally set a final state.
Args:
state: Optional final state to set before marking as finalized.
"""
if self.finalized:
return
self.finalized = True
if state is not None:
self.state = str(state)
self.save(update_fields=['state', 'finalized', 'updated_at'])
else:
self.save(update_fields=['finalized', 'updated_at'])
[docs]
def get_steps(self) -> list[dict[str, Any]]:
"""Return the ordered list of steps from the workflow definition.
Returns:
List of step dictionaries from the workflow definition JSON.
"""
return cast('list[dict[str, Any]]', self.definition.definition.get('steps', []))
[docs]
def get_current_step_index(self) -> int:
"""Return the index of ``self.current_step`` in the steps list, or raise."""
for idx, step in enumerate(self.get_steps()):
if step['id'] == self.current_step:
return idx
msg = f'Unknown current_step {self.current_step!r}'
raise ValueError(msg)
[docs]
def get_next_step(self) -> str | None:
"""Return the step-ID of the next step, or None if at the end.
Returns:
Step ID string of the next step, or None if there is no next step.
"""
idx = self.get_current_step_index()
steps = self.get_steps()
if idx + 1 < len(steps):
return str(steps[idx + 1]['id'])
return None
[docs]
def is_last_approval_step(self) -> bool:
"""Return True if the current step is the last Approval step in the workflow."""
approval_ids = [step['id'] for step in self.get_steps() if step['type'] == 'Approval']
return bool(approval_ids and self.current_step == approval_ids[-1])