Catalyst / admin/Synapse-Cortex 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Synapse-Cortex

public

Self Hosted ITSM Tool with RBAC/Tenanting and MFA

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
Synapse-Cortex / Synapse-Cortexv2 / app / routers / playbooks.py 15798 B · main
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
import os
import uuid
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session

from .. import models, schemas
from ..ai.guardrails import (
    ACKNOWLEDGEABLE_CATEGORIES,
    GuardrailError,
    graph_uses_freeform_command,
    parse_graph_to_commands,
)
from ..audit import log_audit
from ..database import get_db
from ..deps import get_session_user

router = APIRouter(prefix="/api/v1/playbooks", tags=["playbooks"])

ADMIN_ROLES = (models.UserRole.GLOBAL_ADMIN, models.UserRole.TENANT_ADMIN)


def _require_admin_api(user: Optional[models.User]) -> None:
    if not user:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED)
    if user.role not in ADMIN_ROLES:
        raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Admin privileges required")


def _validate_graph(graph_json: dict) -> None:
    try:
        parse_graph_to_commands(graph_json)
    except GuardrailError as exc:
        raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=f"Invalid playbook graph: {exc}")


def _assert_freeform_allowed(user: models.User, graph_json: dict, level: models.ApprovalLevel) -> None:
    """A command-runner playbook (a command node with {{command}}) runs an
    arbitrary run-time command, so it's the one playbook that must be locked
    down at authoring: only a global_admin may create/edit one, and it can
    never be AUTO_APPROVE (a free-form command always requires an explicit
    human approval of the exact command). The run-time guardrails still apply
    on top of this - this is defense in depth at the create boundary."""
    if not graph_uses_freeform_command(graph_json):
        return
    if user.role != models.UserRole.GLOBAL_ADMIN:
        raise HTTPException(
            status.HTTP_403_FORBIDDEN,
            detail="Only a global admin can create or edit a free-form command playbook (one using {{command}}).",
        )
    if level == models.ApprovalLevel.AUTO_APPROVE:
        raise HTTPException(
            status.HTTP_400_BAD_REQUEST,
            detail="A free-form command playbook cannot be Auto-Approve - it must stay Human-in-the-Loop.",
        )


def _assert_acknowledgements_allowed(
    user: models.User, acknowledged: Optional[list[str]], level: models.ApprovalLevel
) -> None:
    """Acknowledging a review-eligible dangerous-command category (e.g.
    host_power → reboot/shutdown) downgrades that category from an auto-block
    to mandatory human review for this playbook. That's a real loosening of a
    safety default, so - like a free-form playbook - only a global_admin may
    set it. Unknown category ids are rejected so a typo can never silently
    fail to take effect (which would look like it worked but still block).

    An acknowledged playbook can NEVER be Auto-Approve: the whole point of an
    acknowledgement is that a human reviews the disruptive command before it
    runs, so it must stay Human-in-the-Loop. This is enforced here at the
    authoring boundary (in addition to the run-time allow-list in
    remediation.py) so the contradictory 'auto-approve + acknowledged' state
    can never even be saved."""
    if not acknowledged:
        return
    unknown = [c for c in acknowledged if c not in ACKNOWLEDGEABLE_CATEGORIES]
    if unknown:
        raise HTTPException(
            status.HTTP_400_BAD_REQUEST,
            detail=(
                f"Unknown acknowledged command category/ies: {', '.join(unknown)}. "
                f"Allowed: {', '.join(sorted(ACKNOWLEDGEABLE_CATEGORIES))}."
            ),
        )
    if user.role != models.UserRole.GLOBAL_ADMIN:
        raise HTTPException(
            status.HTTP_403_FORBIDDEN,
            detail="Only a global admin can acknowledge a dangerous-command category on a playbook.",
        )
    if level == models.ApprovalLevel.AUTO_APPROVE:
        raise HTTPException(
            status.HTTP_400_BAD_REQUEST,
            detail=(
                "A playbook that acknowledges a dangerous-command category cannot be Auto-Approve - "
                "an acknowledged command must always be reviewed and approved by a human."
            ),
        )


def _assert_can_set_approval_level(user: models.User, level: models.ApprovalLevel) -> None:
    """AUTO_APPROVE lets a playbook execute without a human clicking
    Approve - an extra guardrail on the guardrail-setter itself: only
    global_admin may set it, and only when the deployment has explicitly
    opted in via ALLOW_AUTO_APPROVE. Ships false by default so out-of-the-box
    behavior always requires an explicit approval click."""
    if level != models.ApprovalLevel.AUTO_APPROVE:
        return
    if os.getenv("ALLOW_AUTO_APPROVE", "false").lower() != "true":
        raise HTTPException(
            status.HTTP_403_FORBIDDEN,
            detail="AUTO_APPROVE playbooks are disabled for this deployment (set ALLOW_AUTO_APPROVE=true to enable).",
        )
    if user.role != models.UserRole.GLOBAL_ADMIN:
        raise HTTPException(
            status.HTTP_403_FORBIDDEN, detail="Only a global admin can create or edit an AUTO_APPROVE playbook."
        )


@router.get("", response_model=list[schemas.PlaybookOut])
def list_playbooks(db: Session = Depends(get_db), user: Optional[models.User] = Depends(get_session_user)):
    _require_admin_api(user)
    return (
        db.query(models.Playbook)
        .filter(models.Playbook.tenant_id == user.tenant_id)
        .order_by(models.Playbook.name)
        .all()
    )


@router.get("/{playbook_id}", response_model=schemas.PlaybookOut)
def get_playbook(
    playbook_id: uuid.UUID, db: Session = Depends(get_db), user: Optional[models.User] = Depends(get_session_user)
):
    _require_admin_api(user)
    playbook = (
        db.query(models.Playbook)
        .filter(models.Playbook.id == playbook_id, models.Playbook.tenant_id == user.tenant_id)
        .first()
    )
    if not playbook:
        raise HTTPException(status.HTTP_404_NOT_FOUND)
    return playbook


@router.post("", response_model=schemas.PlaybookOut, status_code=status.HTTP_201_CREATED)
def create_playbook(
    payload: schemas.PlaybookCreate,
    db: Session = Depends(get_db),
    user: Optional[models.User] = Depends(get_session_user),
):
    _require_admin_api(user)
    _assert_can_set_approval_level(user, payload.required_approval_level)
    _assert_freeform_allowed(user, payload.graph_json, payload.required_approval_level)
    _assert_acknowledgements_allowed(
        user, payload.acknowledged_dangerous_commands, payload.required_approval_level
    )
    _validate_graph(payload.graph_json)

    existing = (
        db.query(models.Playbook)
        .filter(models.Playbook.tenant_id == user.tenant_id, models.Playbook.name == payload.name)
        .first()
    )
    if existing:
        raise HTTPException(status.HTTP_409_CONFLICT, detail="A playbook with this name already exists")

    playbook = models.Playbook(
        tenant_id=user.tenant_id,
        name=payload.name,
        enabled=payload.enabled,
        graph_json=payload.graph_json,
        allowed_target_os=payload.allowed_target_os,
        required_approval_level=payload.required_approval_level,
        forbidden_commands=payload.forbidden_commands,
        acknowledged_dangerous_commands=payload.acknowledged_dangerous_commands,
        created_by_id=user.id,
    )
    db.add(playbook)
    db.flush()
    log_audit(
        db, tenant_id=user.tenant_id, user_id=user.id, action="playbook_create", detail={"name": playbook.name}
    )
    db.commit()
    db.refresh(playbook)
    return playbook


@router.post("/import", response_model=schemas.PlaybookImportResult)
def import_playbooks(
    payload: schemas.PlaybookImportRequest,
    db: Session = Depends(get_db),
    user: Optional[models.User] = Depends(get_session_user),
):
    """Bulk-import playbooks from an uploaded file (authored offline, or
    exported from another Cortex). Each item is validated independently and
    the batch never aborts as a whole: a name clash is skipped (never
    overwrites a playbook someone may have since edited), an invalid graph
    or a disallowed AUTO_APPROVE level is reported as an error, and valid
    new playbooks are created - the response lists the per-item outcome.
    Goes straight into the running database via the normal API, so no
    container restart is involved."""
    _require_admin_api(user)

    results: list[schemas.PlaybookImportResultItem] = []
    created = skipped = errors = 0
    created_names: list[str] = []

    # Snapshot existing names once, and track names created in this same
    # batch, so two items with the same name in one file don't both create.
    seen_names = {
        row.name
        for row in db.query(models.Playbook.name).filter(models.Playbook.tenant_id == user.tenant_id).all()
    }

    for item in payload.playbooks:
        if item.name in seen_names:
            skipped += 1
            results.append(
                schemas.PlaybookImportResultItem(
                    name=item.name, status="skipped", detail="A playbook with this name already exists."
                )
            )
            continue

        try:
            _assert_can_set_approval_level(user, item.required_approval_level)
            _assert_freeform_allowed(user, item.graph_json, item.required_approval_level)
            _assert_acknowledgements_allowed(
                user, item.acknowledged_dangerous_commands, item.required_approval_level
            )
            _validate_graph(item.graph_json)
        except HTTPException as exc:
            errors += 1
            results.append(schemas.PlaybookImportResultItem(name=item.name, status="error", detail=str(exc.detail)))
            continue

        playbook = models.Playbook(
            tenant_id=user.tenant_id,
            name=item.name,
            enabled=item.enabled,
            graph_json=item.graph_json,
            allowed_target_os=item.allowed_target_os,
            required_approval_level=item.required_approval_level,
            forbidden_commands=item.forbidden_commands,
            acknowledged_dangerous_commands=item.acknowledged_dangerous_commands,
            created_by_id=user.id,
        )
        db.add(playbook)
        db.flush()
        seen_names.add(item.name)
        created += 1
        created_names.append(item.name)
        results.append(schemas.PlaybookImportResultItem(name=item.name, status="created", id=playbook.id))

    # One summary audit row per import batch (mirrors asset_import), rather
    # than flooding the trail with one row per playbook.
    log_audit(
        db,
        tenant_id=user.tenant_id,
        user_id=user.id,
        action="playbook_import",
        detail={"created": created, "skipped": skipped, "errors": errors, "created_names": created_names},
    )
    db.commit()
    return schemas.PlaybookImportResult(created=created, skipped=skipped, errors=errors, results=results)


@router.patch("/{playbook_id}", response_model=schemas.PlaybookOut)
def update_playbook(
    playbook_id: uuid.UUID,
    payload: schemas.PlaybookUpdate,
    db: Session = Depends(get_db),
    user: Optional[models.User] = Depends(get_session_user),
):
    _require_admin_api(user)

    playbook = (
        db.query(models.Playbook)
        .filter(models.Playbook.id == playbook_id, models.Playbook.tenant_id == user.tenant_id)
        .first()
    )
    if not playbook:
        raise HTTPException(status.HTTP_404_NOT_FOUND)

    if payload.required_approval_level is not None:
        _assert_can_set_approval_level(user, payload.required_approval_level)
    if payload.graph_json is not None:
        _validate_graph(payload.graph_json)

    effective_level = (
        payload.required_approval_level
        if payload.required_approval_level is not None
        else playbook.required_approval_level
    )
    # Only CHANGING the acknowledgement set requires the global_admin gate, so
    # a tenant_admin can still edit other fields of a playbook a global_admin
    # acknowledged (the builder re-sends the current value on every save).
    if payload.acknowledged_dangerous_commands is not None and set(
        payload.acknowledged_dangerous_commands
    ) != set(playbook.acknowledged_dangerous_commands or []):
        _assert_acknowledgements_allowed(user, payload.acknowledged_dangerous_commands, effective_level)
    # Separately bar flipping an already-acknowledged playbook to Auto-Approve
    # even when the acknowledgement set itself isn't changing.
    effective_ack = (
        payload.acknowledged_dangerous_commands
        if payload.acknowledged_dangerous_commands is not None
        else (playbook.acknowledged_dangerous_commands or [])
    )
    if effective_ack and effective_level == models.ApprovalLevel.AUTO_APPROVE:
        raise HTTPException(
            status.HTTP_400_BAD_REQUEST,
            detail=(
                "A playbook that acknowledges a dangerous-command category cannot be Auto-Approve - "
                "an acknowledged command must always be reviewed and approved by a human."
            ),
        )

    # Editing a free-form playbook (either the incoming graph or the existing
    # one it's still built on) is gated the same as creating one.
    effective_graph = payload.graph_json if payload.graph_json is not None else playbook.graph_json
    effective_level = (
        payload.required_approval_level
        if payload.required_approval_level is not None
        else playbook.required_approval_level
    )
    _assert_freeform_allowed(user, effective_graph, effective_level)

    changes: dict = {}
    for field, new_value in payload.model_dump(exclude_unset=True).items():
        old_value = getattr(playbook, field)
        old_jsonable = old_value.value if hasattr(old_value, "value") else old_value
        if new_value != old_jsonable:
            changes[field] = {"old": old_jsonable, "new": new_value}
            setattr(playbook, field, new_value)

    if changes:
        log_audit(
            db,
            tenant_id=user.tenant_id,
            user_id=user.id,
            action="playbook_update",
            detail={"name": playbook.name, "changed_fields": list(changes.keys())},
        )
    db.commit()
    db.refresh(playbook)
    return playbook


@router.patch("/{playbook_id}/toggle", response_model=schemas.PlaybookOut)
def toggle_playbook(
    playbook_id: uuid.UUID,
    db: Session = Depends(get_db),
    user: Optional[models.User] = Depends(get_session_user),
):
    _require_admin_api(user)

    playbook = (
        db.query(models.Playbook)
        .filter(models.Playbook.id == playbook_id, models.Playbook.tenant_id == user.tenant_id)
        .first()
    )
    if not playbook:
        raise HTTPException(status.HTTP_404_NOT_FOUND)

    playbook.enabled = not playbook.enabled
    log_audit(
        db,
        tenant_id=user.tenant_id,
        user_id=user.id,
        action="playbook_toggle",
        detail={"name": playbook.name, "enabled": playbook.enabled},
    )
    db.commit()
    db.refresh(playbook)
    return playbook


@router.delete("/{playbook_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_playbook(
    playbook_id: uuid.UUID,
    db: Session = Depends(get_db),
    user: Optional[models.User] = Depends(get_session_user),
):
    _require_admin_api(user)

    playbook = (
        db.query(models.Playbook)
        .filter(models.Playbook.id == playbook_id, models.Playbook.tenant_id == user.tenant_id)
        .first()
    )
    if not playbook:
        raise HTTPException(status.HTTP_404_NOT_FOUND)

    log_audit(db, tenant_id=user.tenant_id, user_id=user.id, action="playbook_delete", detail={"name": playbook.name})
    db.delete(playbook)
    db.commit()
    return None