From 7c9708fa6e98fd809179fad80f2f7f7c6c760bfe Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 13 Mar 2026 00:14:01 -0400 Subject: [PATCH 01/78] feat(scripts): add ScriptCategory, ScriptTemplate, ScriptGeneration models Co-Authored-By: Claude Sonnet 4.6 --- backend/alembic/env.py | 1 + backend/app/models/__init__.py | 4 + backend/app/models/script_template.py | 107 ++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 backend/app/models/script_template.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index ecf4525d..fbc41435 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -18,6 +18,7 @@ from app.models.survey_response import SurveyResponse from app.models.survey_invite import SurveyInvite from app.models.ai_suggestion import AISuggestion # noqa: F401 from app.models.kb_import import KBImport, KBImportNode # noqa: F401 +from app.models.script_template import ScriptCategory, ScriptTemplate, ScriptGeneration # noqa: F401 from app.core.config import settings # this is the Alembic Config object diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2103b987..06003af7 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -35,6 +35,7 @@ from .assistant_chat import AssistantChat from .survey_response import SurveyResponse from .survey_invite import SurveyInvite from .kb_import import KBImport, KBImportNode +from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration __all__ = [ "User", @@ -82,4 +83,7 @@ __all__ = [ "SurveyInvite", "KBImport", "KBImportNode", + "ScriptCategory", + "ScriptTemplate", + "ScriptGeneration", ] diff --git a/backend/app/models/script_template.py b/backend/app/models/script_template.py new file mode 100644 index 00000000..c90c2da7 --- /dev/null +++ b/backend/app/models/script_template.py @@ -0,0 +1,107 @@ +import uuid +from datetime import datetime, timezone +from typing import Optional, TYPE_CHECKING +from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, JSONB +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.user import User + from app.models.team import Team + from app.models.session import Session + + +class ScriptCategory(Base): + __tablename__ = "script_categories" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(100), nullable=False) + slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + icon: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + templates: Mapped[list["ScriptTemplate"]] = relationship("ScriptTemplate", back_populates="category") + + +class ScriptTemplate(Base): + __tablename__ = "script_templates" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + category_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("script_categories.id", ondelete="RESTRICT"), nullable=False, index=True + ) + team_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"), nullable=True, index=True + ) + created_by: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + name: Mapped[str] = mapped_column(String(200), nullable=False) + slug: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + use_case: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + script_body: Mapped[str] = mapped_column(Text, nullable=False) + parameters_schema: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + default_values: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + validation_rules: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + tags: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + complexity: Mapped[str] = mapped_column( + SAEnum("beginner", "intermediate", "advanced", name="script_complexity"), nullable=False, default="beginner" + ) + estimated_runtime: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + requires_elevation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + requires_modules: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + is_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + usage_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + category: Mapped["ScriptCategory"] = relationship("ScriptCategory", back_populates="templates") + team: Mapped[Optional["Team"]] = relationship("Team") + creator: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by]) + generations: Mapped[list["ScriptGeneration"]] = relationship("ScriptGeneration", back_populates="template") + + +class ScriptGeneration(Base): + __tablename__ = "script_generations" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + template_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("script_templates.id", ondelete="RESTRICT"), nullable=False, index=True + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + team_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("teams.id", ondelete="SET NULL"), nullable=True, index=True + ) + session_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="SET NULL"), nullable=True, index=True + ) + parameters_used: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + generated_script: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + template: Mapped["ScriptTemplate"] = relationship("ScriptTemplate", back_populates="generations") + user: Mapped["User"] = relationship("User") -- 2.49.1 From dba0605cec8c7c3e4c3b38375060bf2201afdfd7 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 13 Mar 2026 00:15:17 -0400 Subject: [PATCH 02/78] feat(scripts): add Pydantic schemas for Script Generator Co-Authored-By: Claude Sonnet 4.6 --- backend/app/schemas/__init__.py | 9 ++ backend/app/schemas/script_template.py | 137 +++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 backend/app/schemas/script_template.py diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 1dd46a2e..c761c02f 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -10,6 +10,11 @@ from .ai_builder import ( AIStartResponse, AIScaffoldResponse, AIBranchDetailResponse, AIAssembleResponse, AIQuotaStatusResponse, ) +from .script_template import ( + ScriptCategoryResponse, + ScriptTemplateCreate, ScriptTemplateUpdate, ScriptTemplateListItem, ScriptTemplateDetail, + ScriptGenerateRequest, ScriptGenerateResponse, ScriptGenerationRecord, +) __all__ = [ # User @@ -30,4 +35,8 @@ __all__ = [ "AIStartRequest", "AIScaffoldRequest", "AIBranchDetailRequest", "AIAssembleRequest", "AIStartResponse", "AIScaffoldResponse", "AIBranchDetailResponse", "AIAssembleResponse", "AIQuotaStatusResponse", + # Script Generator + "ScriptCategoryResponse", + "ScriptTemplateCreate", "ScriptTemplateUpdate", "ScriptTemplateListItem", "ScriptTemplateDetail", + "ScriptGenerateRequest", "ScriptGenerateResponse", "ScriptGenerationRecord", ] diff --git a/backend/app/schemas/script_template.py b/backend/app/schemas/script_template.py new file mode 100644 index 00000000..7acb45b4 --- /dev/null +++ b/backend/app/schemas/script_template.py @@ -0,0 +1,137 @@ +from datetime import datetime +from typing import Optional, Any +from uuid import UUID +from pydantic import BaseModel, Field + + +# ── Parameter schema types ────────────────────────────────────────────────── + +class ScriptParameterValidation(BaseModel): + pattern: Optional[str] = None + min_length: Optional[int] = None + max_length: Optional[int] = None + min_value: Optional[float] = None + max_value: Optional[float] = None + +class ScriptParameter(BaseModel): + key: str + label: str + type: str # text | password | select | boolean | multi_text | number | textarea + required: bool = True + placeholder: Optional[str] = None + group: Optional[str] = None + order: int = 0 + help_text: Optional[str] = None + options: Optional[list[dict]] = None # for select type: [{value, label}] + default: Optional[Any] = None + validation: Optional[ScriptParameterValidation] = None + sensitive: bool = False # password fields → redacted in generation record + +class ScriptParametersSchema(BaseModel): + parameters: list[ScriptParameter] + + +# ── Category ──────────────────────────────────────────────────────────────── + +class ScriptCategoryResponse(BaseModel): + id: UUID + name: str + slug: str + description: Optional[str] = None + icon: Optional[str] = None + sort_order: int + template_count: int = 0 + + class Config: + from_attributes = True + + +# ── Template ──────────────────────────────────────────────────────────────── + +class ScriptTemplateCreate(BaseModel): + category_id: UUID + name: str = Field(..., min_length=1, max_length=200) + description: Optional[str] = None + use_case: Optional[str] = None + script_body: str = Field(..., min_length=1) + parameters_schema: dict = Field(default_factory=dict) + default_values: dict = Field(default_factory=dict) + validation_rules: dict = Field(default_factory=dict) + tags: list[str] = Field(default_factory=list) + complexity: str = Field(default="beginner", pattern="^(beginner|intermediate|advanced)$") + estimated_runtime: Optional[str] = None + requires_elevation: bool = False + requires_modules: list[str] = Field(default_factory=list) + +class ScriptTemplateUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = None + use_case: Optional[str] = None + script_body: Optional[str] = None + parameters_schema: Optional[dict] = None + default_values: Optional[dict] = None + validation_rules: Optional[dict] = None + tags: Optional[list[str]] = None + complexity: Optional[str] = Field(None, pattern="^(beginner|intermediate|advanced)$") + estimated_runtime: Optional[str] = None + requires_elevation: Optional[bool] = None + requires_modules: Optional[list[str]] = None + is_active: Optional[bool] = None + +class ScriptTemplateListItem(BaseModel): + id: UUID + category_id: UUID + team_id: Optional[UUID] = None + name: str + slug: str + description: Optional[str] = None + tags: list[str] + complexity: str + estimated_runtime: Optional[str] = None + requires_elevation: bool + requires_modules: list[str] + is_verified: bool + usage_count: int + + class Config: + from_attributes = True + +class ScriptTemplateDetail(ScriptTemplateListItem): + use_case: Optional[str] = None + script_body: str + parameters_schema: dict + default_values: dict + validation_rules: dict + version: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# ── Generation ─────────────────────────────────────────────────────────────── + +class ScriptGenerateRequest(BaseModel): + template_id: UUID + parameters: dict[str, Any] + session_id: Optional[UUID] = None + +class ScriptGenerateResponse(BaseModel): + id: UUID + script: str + warnings: list[str] = Field(default_factory=list) + metadata: dict + + class Config: + from_attributes = True + +class ScriptGenerationRecord(BaseModel): + id: UUID + template_id: UUID + template_name: str + parameters_used: dict # passwords already redacted + created_at: datetime + + class Config: + from_attributes = True -- 2.49.1 From f1ed1fabbb9c1d448a96826a6dabd8cea4175bf2 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 13 Mar 2026 00:17:14 -0400 Subject: [PATCH 03/78] feat(scripts): add ScriptTemplateEngine with substitution, filters, and sanitization Co-Authored-By: Claude Sonnet 4.6 --- .../app/services/script_template_engine.py | 139 ++++++++++++++++++ backend/tests/test_script_template_engine.py | 114 ++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 backend/app/services/script_template_engine.py create mode 100644 backend/tests/test_script_template_engine.py diff --git a/backend/app/services/script_template_engine.py b/backend/app/services/script_template_engine.py new file mode 100644 index 00000000..5641fb63 --- /dev/null +++ b/backend/app/services/script_template_engine.py @@ -0,0 +1,139 @@ +""" +ScriptTemplateEngine — renders parameterized PowerShell templates. + +Template syntax: + {{ param_name }} — simple substitution (string-escaped) + {{ param | filter }} — substitution with filter applied + {% if param %} ... {% endif %} — conditional block + +Security: all string values are PowerShell-escaped before insertion. +Sensitive parameter values are never stored in generation records — use redact_sensitive(). +""" +import re +from typing import Any + + +class ScriptRenderError(Exception): + """Raised when a required parameter is missing or rendering fails.""" + + +class ScriptTemplateEngine: + + # ── Public API ──────────────────────────────────────────────────────── + + def render(self, body: str, parameters: dict[str, Any]) -> str: + """Render a template body with the given parameters. Raises ScriptRenderError on missing required params.""" + result = self._process_conditionals(body, parameters) + result = self._substitute_params(result, parameters) + return result + + def redact_sensitive(self, parameters: dict[str, Any], sensitive_keys: set[str]) -> dict[str, Any]: + """Return a copy of parameters with sensitive values replaced by [REDACTED].""" + return { + k: "[REDACTED]" if k in sensitive_keys else v + for k, v in parameters.items() + } + + # ── Conditional processing ──────────────────────────────────────────── + + def _process_conditionals(self, body: str, parameters: dict[str, Any]) -> str: + """Process {% if param %} ... {% endif %} blocks.""" + pattern = re.compile( + r'\{%\s*if\s+(\w+)\s*%\}(.*?)\{%\s*endif\s*%\}', + re.DOTALL + ) + + def replace_block(match: re.Match) -> str: + key = match.group(1) + content = match.group(2) + value = parameters.get(key) + # Truthy: non-empty string, non-empty list, True, non-zero number + if value is None or value == "" or value == [] or value is False: + return "" + return content + + return pattern.sub(replace_block, body) + + # ── Parameter substitution ──────────────────────────────────────────── + + # Matches {{ param }} or {{ param | filter }} + _PLACEHOLDER = re.compile(r'\{\{\s*(\w+)(?:\s*\|\s*(\w+))?\s*\}\}') + + def _substitute_params(self, body: str, parameters: dict[str, Any]) -> str: + missing: list[str] = [] + + def replace(match: re.Match) -> str: + key = match.group(1) + filter_name = match.group(2) + + if key not in parameters: + missing.append(key) + return match.group(0) # leave as-is, report at end + + value = parameters[key] + + if filter_name: + return self._apply_filter(filter_name, value) + return self._escape_string(value) + + result = self._PLACEHOLDER.sub(replace, body) + + if missing: + raise ScriptRenderError( + f"Missing required template parameter(s): {', '.join(missing)}" + ) + + return result + + # ── Filters ────────────────────────────────────────────────────────── + + def _apply_filter(self, filter_name: str, value: Any) -> str: + filters = { + "as_secure_string": self._filter_as_secure_string, + "as_array": self._filter_as_array, + "as_bool": self._filter_as_bool, + "escape_single": self._filter_escape_single, + } + if filter_name not in filters: + raise ScriptRenderError(f"Unknown template filter: {filter_name}") + return filters[filter_name](value) + + def _filter_as_secure_string(self, value: Any) -> str: + escaped = str(value).replace("'", "''") + return f"(ConvertTo-SecureString '{escaped}' -AsPlainText -Force)" + + def _filter_as_array(self, value: Any) -> str: + if not isinstance(value, list): + value = [value] + items = ",".join(f"'{self._escape_single_quote(str(v))}'" for v in value) + return items + + def _filter_as_bool(self, value: Any) -> str: + if isinstance(value, bool): + return "$true" if value else "$false" + return "$true" if str(value).lower() in ("true", "1", "yes") else "$false" + + def _filter_escape_single(self, value: Any) -> str: + return self._escape_single_quote(str(value)) + + # ── Escaping helpers ────────────────────────────────────────────────── + + def _escape_string(self, value: Any) -> str: + """Escape a value for safe insertion into a PowerShell single-quoted context.""" + if isinstance(value, bool): + return "$true" if value else "$false" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, list): + # Lists rendered without filter — join with space (caller should use | as_array for proper PS arrays) + return " ".join(str(v) for v in value) + s = str(value) + # Escape backtick (PS escape char) first, then dollar (variable interpolation) + s = s.replace("`", "``") + s = s.replace("$", "`$") + # Escape single quotes (doubling for PS single-quoted strings) + s = self._escape_single_quote(s) + return s + + def _escape_single_quote(self, s: str) -> str: + return s.replace("'", "''") diff --git a/backend/tests/test_script_template_engine.py b/backend/tests/test_script_template_engine.py new file mode 100644 index 00000000..e83cc77d --- /dev/null +++ b/backend/tests/test_script_template_engine.py @@ -0,0 +1,114 @@ +"""Tests for ScriptTemplateEngine — parameter substitution, sanitization, and filters.""" +import pytest +from app.services.script_template_engine import ScriptTemplateEngine, ScriptRenderError + + +@pytest.fixture +def engine(): + return ScriptTemplateEngine() + + +# ── Basic substitution ──────────────────────────────────────────────────── + +def test_simple_substitution(engine): + body = "New-ADUser -Name '{{ first_name }} {{ last_name }}'" + result = engine.render(body, {"first_name": "John", "last_name": "Smith"}) + assert result == "New-ADUser -Name 'John Smith'" + + +def test_missing_required_param_raises(engine): + body = "New-ADUser -Name '{{ first_name }}'" + with pytest.raises(ScriptRenderError, match="first_name"): + engine.render(body, {}) + + +def test_extra_params_ignored(engine): + body = "New-ADUser -Name '{{ first_name }}'" + result = engine.render(body, {"first_name": "John", "extra": "ignored"}) + assert result == "New-ADUser -Name 'John'" + + +# ── Security: single-quote injection ───────────────────────────────────── + +def test_single_quote_in_value_is_escaped(engine): + body = "Set-ADUser -Name '{{ name }}'" + result = engine.render(body, {"name": "O'Brien"}) + # Single quotes doubled for PowerShell safety + assert "O''Brien" in result + + +def test_backtick_in_value_is_escaped(engine): + body = "Write-Host '{{ msg }}'" + result = engine.render(body, {"msg": "hello`world"}) + assert "`" not in result or "``" in result # backtick is escaped + + +def test_dollar_sign_in_value_is_escaped(engine): + body = "Write-Host '{{ msg }}'" + result = engine.render(body, {"msg": "price is $100"}) + # Dollar sign escaped so it doesn't interpolate as a PowerShell variable + assert "`$100" in result or "'price is $100'" in result + + +# ── Filters ────────────────────────────────────────────────────────────── + +def test_as_secure_string_filter(engine): + body = "$secPwd = {{ password | as_secure_string }}" + result = engine.render(body, {"password": "MyP@ss123"}) + assert "ConvertTo-SecureString" in result + assert "MyP@ss123" in result + assert "-AsPlainText -Force" in result + + +def test_as_array_filter(engine): + body = "$groups = @({{ groups | as_array }})" + result = engine.render(body, {"groups": ["GroupA", "GroupB"]}) + assert "'GroupA','GroupB'" in result + + +def test_as_array_filter_single_item(engine): + body = "$groups = @({{ groups | as_array }})" + result = engine.render(body, {"groups": ["OnlyGroup"]}) + assert "'OnlyGroup'" in result + + +def test_as_bool_filter_true(engine): + body = "$force = {{ force_change | as_bool }}" + result = engine.render(body, {"force_change": True}) + assert "$true" in result + + +def test_as_bool_filter_false(engine): + body = "$force = {{ force_change | as_bool }}" + result = engine.render(body, {"force_change": False}) + assert "$false" in result + + +# ── Conditional blocks ─────────────────────────────────────────────────── + +def test_if_block_included_when_truthy(engine): + body = "{% if groups %}\nAdd-Groups\n{% endif %}" + result = engine.render(body, {"groups": ["GroupA"]}) + assert "Add-Groups" in result + + +def test_if_block_excluded_when_falsy(engine): + body = "{% if groups %}\nAdd-Groups\n{% endif %}" + result = engine.render(body, {"groups": []}) + assert "Add-Groups" not in result + + +def test_if_block_excluded_when_missing(engine): + body = "{% if groups %}\nAdd-Groups\n{% endif %}" + result = engine.render(body, {}) + assert "Add-Groups" not in result + + +# ── Parameter redaction ────────────────────────────────────────────────── + +def test_sensitive_params_redacted_in_record(engine): + params = {"first_name": "John", "password": "Secret123"} + sensitive_keys = {"password"} + redacted = engine.redact_sensitive(params, sensitive_keys) + assert redacted["first_name"] == "John" + assert redacted["password"] == "[REDACTED]" -- 2.49.1 From 7a3f3b186c4dd720cc36baa7c968f3cc824420bd Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 13 Mar 2026 00:26:03 -0400 Subject: [PATCH 04/78] feat(scripts): add migration 057 - script tables + AD User Management seed templates Co-Authored-By: Claude Sonnet 4.6 --- .../versions/057_add_script_templates.py | 690 ++++++++++++++++++ 1 file changed, 690 insertions(+) create mode 100644 backend/alembic/versions/057_add_script_templates.py diff --git a/backend/alembic/versions/057_add_script_templates.py b/backend/alembic/versions/057_add_script_templates.py new file mode 100644 index 00000000..91efd4c4 --- /dev/null +++ b/backend/alembic/versions/057_add_script_templates.py @@ -0,0 +1,690 @@ +"""add script templates + +Revision ID: 057 +Revises: 056 +Create Date: 2026-03-12 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID, JSONB, ENUM as PG_ENUM +import uuid +import json +from datetime import datetime, timezone + +revision = "057" +down_revision = "056" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ── Create enum ────────────────────────────────────────────────────── + op.execute("CREATE TYPE script_complexity AS ENUM ('beginner', 'intermediate', 'advanced')") + + # ── script_categories ──────────────────────────────────────────────── + op.create_table( + "script_categories", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("slug", sa.String(100), nullable=False, unique=True), + sa.Column("description", sa.Text, nullable=True), + sa.Column("icon", sa.String(50), nullable=True), + sa.Column("sort_order", sa.Integer, nullable=False, server_default=sa.text("0")), + sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_script_categories_slug", "script_categories", ["slug"]) + + # ── script_templates ───────────────────────────────────────────────── + op.create_table( + "script_templates", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("category_id", UUID(as_uuid=True), sa.ForeignKey("script_categories.id", ondelete="RESTRICT"), nullable=False), + sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="CASCADE"), nullable=True), + sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("slug", sa.String(200), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("use_case", sa.Text, nullable=True), + sa.Column("script_body", sa.Text, nullable=False), + sa.Column("parameters_schema", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("default_values", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("validation_rules", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("tags", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")), + sa.Column("complexity", PG_ENUM("beginner", "intermediate", "advanced", name="script_complexity", create_type=False), nullable=False, server_default=sa.text("'beginner'")), + sa.Column("estimated_runtime", sa.String(50), nullable=True), + sa.Column("requires_elevation", sa.Boolean, nullable=False, server_default=sa.text("true")), + sa.Column("requires_modules", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")), + sa.Column("version", sa.Integer, nullable=False, server_default=sa.text("1")), + sa.Column("is_verified", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")), + sa.Column("usage_count", sa.Integer, nullable=False, server_default=sa.text("0")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_script_templates_category_id", "script_templates", ["category_id"]) + op.create_index("ix_script_templates_team_id", "script_templates", ["team_id"]) + op.create_index("ix_script_templates_slug", "script_templates", ["slug"]) + + # ── script_generations ─────────────────────────────────────────────── + op.create_table( + "script_generations", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("template_id", UUID(as_uuid=True), sa.ForeignKey("script_templates.id", ondelete="RESTRICT"), nullable=False), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="SET NULL"), nullable=True), + sa.Column("session_id", UUID(as_uuid=True), sa.ForeignKey("sessions.id", ondelete="SET NULL"), nullable=True), + sa.Column("parameters_used", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("generated_script", sa.Text, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_script_generations_template_id", "script_generations", ["template_id"]) + op.create_index("ix_script_generations_user_id", "script_generations", ["user_id"]) + op.create_index("ix_script_generations_session_id", "script_generations", ["session_id"]) + + # ── Seed: Active Directory category ────────────────────────────────── + now = datetime.now(timezone.utc) + cat_id = uuid.UUID("00000000-0000-0000-0000-000000000001") + + conn = op.get_bind() + conn.execute( + sa.text(""" + INSERT INTO script_categories (id, name, slug, description, icon, sort_order, is_active, created_at, updated_at) + VALUES (:id, :name, :slug, :description, :icon, :sort_order, true, :now, :now) + """), + { + "id": cat_id, + "name": "Active Directory", + "slug": "active-directory", + "description": "User account and group management scripts for Active Directory environments", + "icon": "shield-check", + "sort_order": 1, + "now": now, + } + ) + + templates = _get_seed_templates(cat_id, now) + for tmpl in templates: + conn.execute( + sa.text(""" + INSERT INTO script_templates ( + id, category_id, name, slug, description, use_case, + script_body, parameters_schema, default_values, validation_rules, + tags, complexity, estimated_runtime, requires_elevation, + requires_modules, version, is_verified, is_active, usage_count, + created_at, updated_at + ) VALUES ( + :id, :category_id, :name, :slug, :description, :use_case, + :script_body, CAST(:parameters_schema AS jsonb), CAST(:default_values AS jsonb), CAST(:validation_rules AS jsonb), + CAST(:tags AS jsonb), :complexity, :estimated_runtime, :requires_elevation, + CAST(:requires_modules AS jsonb), 1, true, true, 0, + :now, :now + ) + """), + tmpl, + ) + + +def downgrade() -> None: + op.drop_table("script_generations") + op.drop_table("script_templates") + op.drop_table("script_categories") + op.execute("DROP TYPE IF EXISTS script_complexity") + + +def _get_seed_templates(cat_id: uuid.UUID, now: datetime) -> list[dict]: + """Return seed data for the six AD User Management templates.""" + + def tmpl( + id_suffix: int, + name: str, + slug: str, + description: str, + use_case: str, + script_body: str, + parameters_schema: dict, + complexity: str, + estimated_runtime: str, + requires_elevation: bool, + tags: list[str], + ) -> dict: + return { + "id": uuid.UUID(f"00000000-0000-0000-0001-{id_suffix:012d}"), + "category_id": cat_id, + "name": name, + "slug": slug, + "description": description, + "use_case": use_case, + "script_body": script_body, + "parameters_schema": json.dumps(parameters_schema), + "default_values": json.dumps({}), + "validation_rules": json.dumps({}), + "tags": json.dumps(tags), + "complexity": complexity, + "estimated_runtime": estimated_runtime, + "requires_elevation": requires_elevation, + "requires_modules": json.dumps([]), + "now": now, + } + + return [ + + # ── 1: Create AD User ───────────────────────────────────────────── + tmpl( + id_suffix=1, + name="Create AD User Account", + slug="create-ad-user", + description="Creates a new Active Directory user account with full property configuration.", + use_case="Use when onboarding a new employee or creating a service account in Active Directory.", + complexity="intermediate", + estimated_runtime="< 5 seconds", + requires_elevation=True, + tags=["active-directory", "user-management", "onboarding"], + parameters_schema={ + "parameters": [ + {"key": "first_name", "label": "First Name", "type": "text", "required": True, "group": "User Identity", "order": 1, "validation": {"pattern": "^[a-zA-Z\\-']+$", "maxLength": 64}}, + {"key": "last_name", "label": "Last Name", "type": "text", "required": True, "group": "User Identity", "order": 2, "validation": {"pattern": "^[a-zA-Z\\-']+$", "maxLength": 64}}, + {"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "group": "User Identity", "order": 3, "placeholder": "jsmith", "help_text": "The logon name (typically firstname.lastname or first initial + last name). Max 20 characters."}, + {"key": "upn_suffix", "label": "UPN Suffix", "type": "text", "required": True, "group": "User Identity", "order": 4, "placeholder": "contoso.com"}, + {"key": "ou_path", "label": "Organizational Unit", "type": "text", "required": True, "group": "AD Configuration", "order": 5, "placeholder": "OU=Users,DC=contoso,DC=com", "validation": {"pattern": "^(OU|CN)=.+"}}, + {"key": "password", "label": "Initial Password", "type": "password", "required": True, "group": "Security", "order": 6, "sensitive": True}, + {"key": "job_title", "label": "Job Title", "type": "text", "required": False, "group": "Profile", "order": 7}, + {"key": "department", "label": "Department", "type": "text", "required": False, "group": "Profile", "order": 8}, + {"key": "groups", "label": "Security Groups", "type": "multi_text", "required": False, "group": "Group Membership", "order": 9, "placeholder": "Domain Users"}, + {"key": "force_password_change", "label": "Force Password Change at Logon", "type": "boolean", "required": False, "group": "Security", "order": 10, "default": True}, + {"key": "account_enabled", "label": "Enable Account", "type": "boolean", "required": False, "group": "Security", "order": 11, "default": True}, + ] + }, + script_body=r"""# ============================================================ +# Create AD User Account +# Generated by ResolutionFlow Script Generator +# Template Version: 1 +# ============================================================ + +#Requires -Modules ActiveDirectory +#Requires -RunAsAdministrator + +$FirstName = '{{ first_name }}' +$LastName = '{{ last_name }}' +$SamAccountName = '{{ sam_account_name }}' +$UPNSuffix = '{{ upn_suffix }}' +$OUPath = '{{ ou_path }}' +$Password = {{ password | as_secure_string }} +$ForcePasswordChange = {{ force_password_change | as_bool }} +$AccountEnabled = {{ account_enabled | as_bool }} +{% if job_title %} +$JobTitle = '{{ job_title }}' +{% endif %} +{% if department %} +$Department = '{{ department }}' +{% endif %} +{% if groups %} +$Groups = @({{ groups | as_array }}) +{% endif %} + +$DisplayName = "$FirstName $LastName" +$UPN = "$SamAccountName@$UPNSuffix" + +try { + Import-Module ActiveDirectory -ErrorAction Stop + Write-Host "[CHECK] ActiveDirectory module loaded" -ForegroundColor Green +} catch { + Write-Host "[ERROR] ActiveDirectory module not available: $_" -ForegroundColor Red + exit 1 +} + +try { + $userParams = @{ + Name = $DisplayName + GivenName = $FirstName + Surname = $LastName + SamAccountName = $SamAccountName + UserPrincipalName = $UPN + Path = $OUPath + AccountPassword = $Password + Enabled = $AccountEnabled + ChangePasswordAtLogon = $ForcePasswordChange + } +{% if job_title %} + $userParams["Title"] = $JobTitle +{% endif %} +{% if department %} + $userParams["Department"] = $Department +{% endif %} + + New-ADUser @userParams -ErrorAction Stop + Write-Host "[OK] User account created: $SamAccountName ($DisplayName)" -ForegroundColor Green +} catch { + Write-Host "[ERROR] Failed to create user: $_" -ForegroundColor Red + exit 1 +} + +{% if groups %} +foreach ($group in $Groups) { + try { + Add-ADGroupMember -Identity $group -Members $SamAccountName -ErrorAction Stop + Write-Host "[OK] Added to group: $group" -ForegroundColor Green + } catch { + Write-Host "[WARN] Could not add to group '$group': $_" -ForegroundColor Yellow + } +} +{% endif %} + +Write-Host "" +Write-Host "[DONE] User $SamAccountName created successfully." -ForegroundColor Cyan +Write-Host " UPN: $UPN" -ForegroundColor Cyan +""", + ), + + # ── 2: Disable AD User ──────────────────────────────────────────── + tmpl( + id_suffix=2, + name="Disable AD User Account", + slug="disable-ad-user", + description="Disables an Active Directory user account with optional group removal and OU relocation.", + use_case="Use when offboarding an employee or temporarily suspending access.", + complexity="beginner", + estimated_runtime="< 5 seconds", + requires_elevation=True, + tags=["active-directory", "user-management", "offboarding"], + parameters_schema={ + "parameters": [ + {"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "group": "Target Account", "order": 1, "placeholder": "jsmith"}, + {"key": "disable_reason", "label": "Reason", "type": "text", "required": False, "group": "Audit", "order": 2, "placeholder": "Employee offboarding"}, + {"key": "remove_groups", "label": "Remove from All Groups", "type": "boolean", "required": False, "group": "Options", "order": 3, "default": False}, + {"key": "move_to_disabled_ou", "label": "Move to Disabled OU", "type": "boolean", "required": False, "group": "Options", "order": 4, "default": False}, + {"key": "disabled_ou_path", "label": "Disabled OU Path", "type": "text", "required": False, "group": "Options", "order": 5, "placeholder": "OU=Disabled,DC=contoso,DC=com"}, + ] + }, + script_body=r"""# ============================================================ +# Disable AD User Account +# Generated by ResolutionFlow Script Generator +# ============================================================ + +#Requires -Modules ActiveDirectory +#Requires -RunAsAdministrator + +$SamAccountName = '{{ sam_account_name }}' +{% if disable_reason %} +$DisableReason = '{{ disable_reason }}' +{% endif %} +$RemoveGroups = {{ remove_groups | as_bool }} +$MoveToDisabled = {{ move_to_disabled_ou | as_bool }} +{% if disabled_ou_path %} +$DisabledOUPath = '{{ disabled_ou_path }}' +{% endif %} + +try { + Import-Module ActiveDirectory -ErrorAction Stop +} catch { + Write-Host "[ERROR] ActiveDirectory module not available: $_" -ForegroundColor Red; exit 1 +} + +try { + $user = Get-ADUser -Identity $SamAccountName -Properties MemberOf -ErrorAction Stop + Write-Host "[CHECK] Found user: $($user.DistinguishedName)" -ForegroundColor Green +} catch { + Write-Host "[ERROR] User not found: $SamAccountName" -ForegroundColor Red; exit 1 +} + +try { + Disable-ADAccount -Identity $SamAccountName -ErrorAction Stop + Write-Host "[OK] Account disabled: $SamAccountName" -ForegroundColor Green +{% if disable_reason %} + Set-ADUser -Identity $SamAccountName -Description $DisableReason + Write-Host "[OK] Disable reason recorded in Description field" -ForegroundColor Green +{% endif %} +} catch { + Write-Host "[ERROR] Failed to disable account: $_" -ForegroundColor Red; exit 1 +} + +{% if remove_groups %} +if ($RemoveGroups) { + $groups = $user.MemberOf + foreach ($group in $groups) { + try { + Remove-ADGroupMember -Identity $group -Members $SamAccountName -Confirm:$false -ErrorAction Stop + Write-Host "[OK] Removed from group: $group" -ForegroundColor Green + } catch { + Write-Host "[WARN] Could not remove from group: $group" -ForegroundColor Yellow + } + } +} +{% endif %} + +{% if move_to_disabled_ou %} +if ($MoveToDisabled) { + try { + Move-ADObject -Identity $user.DistinguishedName -TargetPath $DisabledOUPath -ErrorAction Stop + Write-Host "[OK] Moved to disabled OU: $DisabledOUPath" -ForegroundColor Green + } catch { + Write-Host "[WARN] Could not move to disabled OU: $_" -ForegroundColor Yellow + } +} +{% endif %} + +Write-Host "" +Write-Host "[DONE] Account $SamAccountName has been disabled." -ForegroundColor Cyan +""", + ), + + # ── 3: Reset AD Password ────────────────────────────────────────── + tmpl( + id_suffix=3, + name="Reset AD Password", + slug="reset-ad-password", + description="Resets an Active Directory user password with optional account unlock.", + use_case="Use when a user has forgotten their password or their account is locked out.", + complexity="beginner", + estimated_runtime="< 5 seconds", + requires_elevation=True, + tags=["active-directory", "password", "account-management"], + parameters_schema={ + "parameters": [ + {"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "group": "Target Account", "order": 1}, + {"key": "new_password", "label": "New Password", "type": "password", "required": True, "group": "Security", "order": 2, "sensitive": True}, + {"key": "force_change_at_logon", "label": "Force Change at Next Logon", "type": "boolean", "required": False, "group": "Security", "order": 3, "default": True}, + {"key": "unlock_account", "label": "Unlock Account if Locked", "type": "boolean", "required": False, "group": "Options", "order": 4, "default": True}, + ] + }, + script_body=r"""# ============================================================ +# Reset AD Password +# Generated by ResolutionFlow Script Generator +# ============================================================ + +#Requires -Modules ActiveDirectory +#Requires -RunAsAdministrator + +$SamAccountName = '{{ sam_account_name }}' +$NewPassword = {{ new_password | as_secure_string }} +$ForceChange = {{ force_change_at_logon | as_bool }} +$UnlockAccount = {{ unlock_account | as_bool }} + +try { Import-Module ActiveDirectory -ErrorAction Stop } catch { Write-Host "[ERROR] $_ " -ForegroundColor Red; exit 1 } + +try { + Set-ADAccountPassword -Identity $SamAccountName -NewPassword $NewPassword -Reset -ErrorAction Stop + Write-Host "[OK] Password reset for: $SamAccountName" -ForegroundColor Green +} catch { + Write-Host "[ERROR] Failed to reset password: $_" -ForegroundColor Red; exit 1 +} + +if ($ForceChange) { + Set-ADUser -Identity $SamAccountName -ChangePasswordAtLogon $true + Write-Host "[OK] User must change password at next logon" -ForegroundColor Green +} + +if ($UnlockAccount) { + try { + Unlock-ADAccount -Identity $SamAccountName -ErrorAction Stop + Write-Host "[OK] Account unlocked" -ForegroundColor Green + } catch { + Write-Host "[WARN] Could not unlock account (may not have been locked): $_" -ForegroundColor Yellow + } +} + +Write-Host "" +Write-Host "[DONE] Password reset complete for $SamAccountName." -ForegroundColor Cyan +""", + ), + + # ── 4: Unlock AD Account ────────────────────────────────────────── + tmpl( + id_suffix=4, + name="Unlock AD Account", + slug="unlock-ad-account", + description="Unlocks a locked-out Active Directory user account and optionally shows lockout information.", + use_case="Use when a user is locked out after too many failed login attempts.", + complexity="beginner", + estimated_runtime="< 5 seconds", + requires_elevation=True, + tags=["active-directory", "account-management", "lockout"], + parameters_schema={ + "parameters": [ + {"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "group": "Target Account", "order": 1}, + {"key": "show_lockout_info", "label": "Show Lockout Source Info", "type": "boolean", "required": False, "group": "Options", "order": 2, "default": False, "help_text": "Queries domain controllers for lockout source (may be slow)"}, + ] + }, + script_body=r"""# ============================================================ +# Unlock AD Account +# Generated by ResolutionFlow Script Generator +# ============================================================ + +#Requires -Modules ActiveDirectory +#Requires -RunAsAdministrator + +$SamAccountName = '{{ sam_account_name }}' +$ShowLockoutInfo = {{ show_lockout_info | as_bool }} + +try { Import-Module ActiveDirectory -ErrorAction Stop } catch { Write-Host "[ERROR] $_ " -ForegroundColor Red; exit 1 } + +try { + $user = Get-ADUser -Identity $SamAccountName -Properties LockedOut, BadLogonCount, LastBadPasswordAttempt -ErrorAction Stop + Write-Host "[CHECK] User found: $($user.DistinguishedName)" -ForegroundColor Green + Write-Host " Locked Out: $($user.LockedOut)" -ForegroundColor Cyan + Write-Host " Bad Logon Count: $($user.BadLogonCount)" -ForegroundColor Cyan + Write-Host " Last Bad Password Attempt: $($user.LastBadPasswordAttempt)" -ForegroundColor Cyan +} catch { + Write-Host "[ERROR] User not found: $SamAccountName" -ForegroundColor Red; exit 1 +} + +if (-not $user.LockedOut) { + Write-Host "[INFO] Account is not currently locked." -ForegroundColor Yellow +} else { + try { + Unlock-ADAccount -Identity $SamAccountName -ErrorAction Stop + Write-Host "[OK] Account unlocked: $SamAccountName" -ForegroundColor Green + } catch { + Write-Host "[ERROR] Failed to unlock account: $_" -ForegroundColor Red; exit 1 + } +} + +{% if show_lockout_info %} +if ($ShowLockoutInfo) { + Write-Host "" + Write-Host "[INFO] Querying domain controllers for lockout source..." -ForegroundColor Cyan + try { + $DCs = Get-ADDomainController -Filter * | Select-Object -ExpandProperty HostName + foreach ($DC in $DCs) { + $events = Get-WinEvent -ComputerName $DC -FilterHashtable @{ + LogName = "Security" + Id = 4740 + } -MaxEvents 10 -ErrorAction SilentlyContinue | + Where-Object { $_.Properties[0].Value -eq $SamAccountName } + foreach ($e in $events) { + Write-Host " DC: $DC | Source: $($e.Properties[1].Value) | Time: $($e.TimeCreated)" -ForegroundColor Yellow + } + } + } catch { + Write-Host "[WARN] Could not query lockout source: $_" -ForegroundColor Yellow + } +} +{% endif %} + +Write-Host "" +Write-Host "[DONE] Unlock operation complete for $SamAccountName." -ForegroundColor Cyan +""", + ), + + # ── 5: Delete AD User ───────────────────────────────────────────── + tmpl( + id_suffix=5, + name="Delete AD User Account", + slug="delete-ad-user", + description="Permanently deletes an Active Directory user account with optional CSV backup of user properties.", + use_case="Use when permanently removing an account that is no longer needed. Prefer Disable for departing employees.", + complexity="advanced", + estimated_runtime="< 10 seconds", + requires_elevation=True, + tags=["active-directory", "user-management", "destructive"], + parameters_schema={ + "parameters": [ + {"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "group": "Target Account", "order": 1}, + {"key": "confirm_deletion", "label": "I confirm this account should be permanently deleted", "type": "boolean", "required": True, "group": "Confirmation", "order": 2, "default": False}, + {"key": "backup_to_csv", "label": "Back Up User Properties to CSV First", "type": "boolean", "required": False, "group": "Options", "order": 3, "default": True}, + {"key": "backup_path", "label": "CSV Backup Path", "type": "text", "required": False, "group": "Options", "order": 4, "placeholder": "C:\\Backups\\ad-user-backup.csv"}, + ] + }, + script_body=r"""# ============================================================ +# Delete AD User Account +# Generated by ResolutionFlow Script Generator +# DESTRUCTIVE OPERATION +# ============================================================ + +#Requires -Modules ActiveDirectory +#Requires -RunAsAdministrator + +$SamAccountName = '{{ sam_account_name }}' +$ConfirmDeletion = {{ confirm_deletion | as_bool }} +$BackupToCSV = {{ backup_to_csv | as_bool }} +{% if backup_path %} +$BackupPath = '{{ backup_path }}' +{% else %} +$BackupPath = "C:\Temp\ad-user-backup-$SamAccountName-$(Get-Date -Format 'yyyyMMdd-HHmmss').csv" +{% endif %} + +try { Import-Module ActiveDirectory -ErrorAction Stop } catch { Write-Host "[ERROR] $_ " -ForegroundColor Red; exit 1 } + +if (-not $ConfirmDeletion) { + Write-Host "[ABORT] Deletion not confirmed. Set confirm_deletion to true to proceed." -ForegroundColor Red + exit 1 +} + +try { + $user = Get-ADUser -Identity $SamAccountName -Properties * -ErrorAction Stop + Write-Host "[CHECK] Found user: $($user.DistinguishedName)" -ForegroundColor Yellow + Write-Host "[WARN] About to permanently delete this account." -ForegroundColor Yellow +} catch { + Write-Host "[ERROR] User not found: $SamAccountName" -ForegroundColor Red; exit 1 +} + +if ($BackupToCSV) { + try { + $user | Select-Object Name, SamAccountName, UserPrincipalName, DistinguishedName, EmailAddress, Title, Department, Enabled, Created, Modified | + Export-Csv -Path $BackupPath -NoTypeInformation -ErrorAction Stop + Write-Host "[OK] User properties backed up to: $BackupPath" -ForegroundColor Green + } catch { + Write-Host "[WARN] Could not write backup: $_" -ForegroundColor Yellow + } +} + +try { + Remove-ADUser -Identity $SamAccountName -Confirm:$false -ErrorAction Stop + Write-Host "[OK] User account permanently deleted: $SamAccountName" -ForegroundColor Green +} catch { + Write-Host "[ERROR] Failed to delete user: $_" -ForegroundColor Red; exit 1 +} + +Write-Host "" +Write-Host "[DONE] Account $SamAccountName has been permanently deleted." -ForegroundColor Cyan +""", + ), + + # ── 6: Bulk User Import ─────────────────────────────────────────── + tmpl( + id_suffix=6, + name="Bulk User Import from CSV", + slug="bulk-user-import", + description="Imports multiple Active Directory user accounts from a CSV file with dry-run support and detailed logging.", + use_case="Use when onboarding multiple employees at once from an HR system export.", + complexity="advanced", + estimated_runtime="1-2 minutes", + requires_elevation=True, + tags=["active-directory", "bulk", "onboarding", "csv"], + parameters_schema={ + "parameters": [ + {"key": "csv_path", "label": "CSV File Path", "type": "text", "required": True, "group": "Source", "order": 1, "placeholder": "C:\\Imports\\new-users.csv", "help_text": "CSV must have columns: FirstName, LastName, SamAccountName, UPN, Department, JobTitle"}, + {"key": "ou_path", "label": "Target Organizational Unit", "type": "text", "required": True, "group": "AD Configuration", "order": 2, "placeholder": "OU=Users,DC=contoso,DC=com"}, + {"key": "default_password", "label": "Default Password", "type": "password", "required": True, "group": "Security", "order": 3, "sensitive": True}, + {"key": "groups", "label": "Add All Users to Groups", "type": "multi_text", "required": False, "group": "Group Membership", "order": 4}, + {"key": "force_password_change", "label": "Force Password Change at Logon", "type": "boolean", "required": False, "group": "Security", "order": 5, "default": True}, + {"key": "log_path", "label": "Log File Path", "type": "text", "required": False, "group": "Options", "order": 6, "placeholder": "C:\\Logs\\bulk-import.log"}, + {"key": "dry_run", "label": "Dry Run (Preview Only - No Changes Made)", "type": "boolean", "required": False, "group": "Options", "order": 7, "default": False}, + ] + }, + script_body=r"""# ============================================================ +# Bulk User Import from CSV +# Generated by ResolutionFlow Script Generator +# ============================================================ +# CSV Format: FirstName, LastName, SamAccountName, UPN, Department, JobTitle +# ============================================================ + +#Requires -Modules ActiveDirectory +#Requires -RunAsAdministrator + +$CSVPath = '{{ csv_path }}' +$OUPath = '{{ ou_path }}' +$DefaultPassword = {{ default_password | as_secure_string }} +$ForceChange = {{ force_password_change | as_bool }} +$DryRun = {{ dry_run | as_bool }} +{% if log_path %} +$LogPath = '{{ log_path }}' +{% else %} +$LogPath = "C:\Temp\bulk-import-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" +{% endif %} +{% if groups %} +$DefaultGroups = @({{ groups | as_array }}) +{% endif %} + +function Write-Log { + param($Message, $Level = "INFO") + $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message" + Add-Content -Path $LogPath -Value $line -ErrorAction SilentlyContinue + $color = switch ($Level) { "ERROR" { "Red" } "WARN" { "Yellow" } "OK" { "Green" } default { "White" } } + Write-Host $line -ForegroundColor $color +} + +try { Import-Module ActiveDirectory -ErrorAction Stop } catch { Write-Log "ActiveDirectory module not available: $_" "ERROR"; exit 1 } + +if (-not (Test-Path $CSVPath)) { Write-Log "CSV file not found: $CSVPath" "ERROR"; exit 1 } + +$users = Import-Csv -Path $CSVPath +Write-Log "Loaded $($users.Count) users from CSV" +if ($DryRun) { Write-Log "DRY RUN MODE - no changes will be made" "WARN" } + +$successCount = 0 +$errorCount = 0 + +foreach ($row in $users) { + $sam = $row.SamAccountName + try { + $existing = Get-ADUser -Filter "SamAccountName -eq '$sam'" -ErrorAction SilentlyContinue + if ($existing) { Write-Log "SKIP: $sam already exists" "WARN"; continue } + + if (-not $DryRun) { + New-ADUser ` + -Name "$($row.FirstName) $($row.LastName)" ` + -GivenName $row.FirstName ` + -Surname $row.LastName ` + -SamAccountName $sam ` + -UserPrincipalName $row.UPN ` + -Path $OUPath ` + -AccountPassword $DefaultPassword ` + -Enabled $true ` + -ChangePasswordAtLogon $ForceChange ` + -Title $row.JobTitle ` + -Department $row.Department ` + -ErrorAction Stop + +{% if groups %} + foreach ($group in $DefaultGroups) { + try { Add-ADGroupMember -Identity $group -Members $sam -ErrorAction Stop } + catch { Write-Log "WARN: $sam - could not add to group '$group'" "WARN" } + } +{% endif %} + } + Write-Log "$(if ($DryRun) { 'PREVIEW' } else { 'CREATED' }): $sam ($($row.FirstName) $($row.LastName))" "OK" + $successCount++ + } catch { + Write-Log "FAILED: $sam - $_" "ERROR" + $errorCount++ + } +} + +Write-Log "" +Write-Log "[DONE] Import complete. Success: $successCount | Errors: $errorCount | Log: $LogPath" "INFO" +""", + ), + + ] -- 2.49.1 From 7eae1675975e254a6cf43b08d67b3ad8876d33b2 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 13 Mar 2026 00:30:17 -0400 Subject: [PATCH 05/78] feat(scripts): add Script Generator API endpoints and integration tests Implements REST endpoints for script categories, templates, generation, and generation history. Includes 12 integration tests covering auth, CRUD, search/filter, parameter validation, and sensitive field redaction. Co-Authored-By: Claude Opus 4.6 --- backend/app/api/endpoints/scripts.py | 356 +++++++++++++++++++++++++++ backend/app/api/router.py | 2 + backend/tests/test_scripts.py | 336 +++++++++++++++++++++++++ 3 files changed, 694 insertions(+) create mode 100644 backend/app/api/endpoints/scripts.py create mode 100644 backend/tests/test_scripts.py diff --git a/backend/app/api/endpoints/scripts.py b/backend/app/api/endpoints/scripts.py new file mode 100644 index 00000000..e7ab9158 --- /dev/null +++ b/backend/app/api/endpoints/scripts.py @@ -0,0 +1,356 @@ +"""Script Generator API endpoints.""" +from typing import Annotated, Optional +from uuid import UUID +import re + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, or_ + +from app.core.database import get_db +from app.api.deps import get_current_active_user +from app.models.user import User +from app.models.script_template import ScriptCategory, ScriptTemplate, ScriptGeneration +from app.schemas.script_template import ( + ScriptCategoryResponse, + ScriptTemplateCreate, + ScriptTemplateUpdate, + ScriptTemplateListItem, + ScriptTemplateDetail, + ScriptGenerateRequest, + ScriptGenerateResponse, + ScriptGenerationRecord, +) +from app.services.script_template_engine import ScriptTemplateEngine, ScriptRenderError + +router = APIRouter(prefix="/scripts", tags=["scripts"]) +_engine = ScriptTemplateEngine() + + +def _require_team_admin(user: User) -> None: + """Raise 403 if user is not a team admin or super admin.""" + if not (user.is_team_admin or user.is_super_admin): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Team admin access required", + ) + + +# ── Categories ──────────────────────────────────────────────────────────── + + +@router.get("/categories", response_model=list[ScriptCategoryResponse]) +async def list_categories( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> list[ScriptCategoryResponse]: + result = await db.execute( + select(ScriptCategory) + .where(ScriptCategory.is_active == True) # noqa: E712 + .order_by(ScriptCategory.sort_order) + ) + categories = result.scalars().all() + + count_result = await db.execute( + select(ScriptTemplate.category_id, func.count(ScriptTemplate.id)) + .where(ScriptTemplate.is_active == True) # noqa: E712 + .group_by(ScriptTemplate.category_id) + ) + counts = dict(count_result.all()) + + return [ + ScriptCategoryResponse( + id=cat.id, + name=cat.name, + slug=cat.slug, + description=cat.description, + icon=cat.icon, + sort_order=cat.sort_order, + template_count=counts.get(cat.id, 0), + ) + for cat in categories + ] + + +# ── Templates ───────────────────────────────────────────────────────────── + + +@router.get("/templates", response_model=list[ScriptTemplateListItem]) +async def list_templates( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], + category_slug: Optional[str] = Query(None), + search: Optional[str] = Query(None), + tags: Optional[str] = Query(None, description="Comma-separated tags"), +) -> list[ScriptTemplateListItem]: + query = ( + select(ScriptTemplate) + .join(ScriptCategory, ScriptTemplate.category_id == ScriptCategory.id) + .where(ScriptTemplate.is_active == True) # noqa: E712 + .where( + or_( + ScriptTemplate.team_id == None, # noqa: E711 + ScriptTemplate.team_id == current_user.team_id, + ) + ) + ) + + if category_slug: + query = query.where(ScriptCategory.slug == category_slug) + + if search: + term = f"%{search.lower()}%" + query = query.where( + or_( + func.lower(ScriptTemplate.name).like(term), + func.lower(ScriptTemplate.description).like(term), + func.lower(ScriptTemplate.slug).like(term), + ) + ) + + result = await db.execute(query.order_by(ScriptTemplate.name)) + templates = result.scalars().all() + + if tags: + tag_list = [t.strip().lower() for t in tags.split(",")] + templates = [ + t + for t in templates + if any(tag in [tg.lower() for tg in (t.tags or [])] for tag in tag_list) + ] + + return [ScriptTemplateListItem.model_validate(t) for t in templates] + + +@router.get("/templates/{template_id}", response_model=ScriptTemplateDetail) +async def get_template( + template_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> ScriptTemplateDetail: + result = await db.execute( + select(ScriptTemplate).where( + ScriptTemplate.id == template_id, + ScriptTemplate.is_active == True, # noqa: E712 + or_( + ScriptTemplate.team_id == None, # noqa: E711 + ScriptTemplate.team_id == current_user.team_id, + ), + ) + ) + template = result.scalar_one_or_none() + if not template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Template not found" + ) + return ScriptTemplateDetail.model_validate(template) + + +@router.post( + "/templates", + response_model=ScriptTemplateDetail, + status_code=status.HTTP_201_CREATED, +) +async def create_template( + data: ScriptTemplateCreate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> ScriptTemplateDetail: + _require_team_admin(current_user) + + cat_result = await db.execute( + select(ScriptCategory).where( + ScriptCategory.id == data.category_id, + ScriptCategory.is_active == True, # noqa: E712 + ) + ) + if not cat_result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Category not found" + ) + + slug = re.sub(r"[^a-z0-9]+", "-", data.name.lower()).strip("-") + + template = ScriptTemplate( + category_id=data.category_id, + team_id=current_user.team_id, + created_by=current_user.id, + name=data.name, + slug=slug, + description=data.description, + use_case=data.use_case, + script_body=data.script_body, + parameters_schema=data.parameters_schema, + default_values=data.default_values, + validation_rules=data.validation_rules, + tags=data.tags, + complexity=data.complexity, + estimated_runtime=data.estimated_runtime, + requires_elevation=data.requires_elevation, + requires_modules=data.requires_modules, + ) + db.add(template) + await db.commit() + await db.refresh(template) + return ScriptTemplateDetail.model_validate(template) + + +@router.put("/templates/{template_id}", response_model=ScriptTemplateDetail) +async def update_template( + template_id: UUID, + data: ScriptTemplateUpdate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> ScriptTemplateDetail: + _require_team_admin(current_user) + + result = await db.execute( + select(ScriptTemplate).where( + ScriptTemplate.id == template_id, + ScriptTemplate.team_id == current_user.team_id, + ) + ) + template = result.scalar_one_or_none() + if not template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Template not found or not editable", + ) + + update_data = data.model_dump(exclude_unset=True) + if "script_body" in update_data or "parameters_schema" in update_data: + template.version += 1 + for field, value in update_data.items(): + setattr(template, field, value) + + await db.commit() + await db.refresh(template) + return ScriptTemplateDetail.model_validate(template) + + +@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_template( + template_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> None: + _require_team_admin(current_user) + + result = await db.execute( + select(ScriptTemplate).where( + ScriptTemplate.id == template_id, + ScriptTemplate.team_id == current_user.team_id, + ) + ) + template = result.scalar_one_or_none() + if not template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Template not found or not deletable", + ) + + template.is_active = False + await db.commit() + + +# ── Generate ────────────────────────────────────────────────────────────── + + +@router.post("/generate", response_model=ScriptGenerateResponse) +async def generate_script( + data: ScriptGenerateRequest, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> ScriptGenerateResponse: + result = await db.execute( + select(ScriptTemplate).where( + ScriptTemplate.id == data.template_id, + ScriptTemplate.is_active == True, # noqa: E712 + or_( + ScriptTemplate.team_id == None, # noqa: E711 + ScriptTemplate.team_id == current_user.team_id, + ), + ) + ) + template = result.scalar_one_or_none() + if not template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Template not found" + ) + + try: + rendered_script = _engine.render(template.script_body, data.parameters) + except ScriptRenderError as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) + ) + + params_schema = template.parameters_schema or {} + sensitive_keys = { + p["key"] + for p in params_schema.get("parameters", []) + if p.get("sensitive", False) + } + redacted_params = _engine.redact_sensitive(data.parameters, sensitive_keys) + + generation = ScriptGeneration( + template_id=template.id, + user_id=current_user.id, + team_id=current_user.team_id, + session_id=data.session_id, + parameters_used=redacted_params, + generated_script=rendered_script, + ) + db.add(generation) + template.usage_count += 1 + await db.commit() + await db.refresh(generation) + + warnings: list[str] = [] + if template.requires_elevation: + warnings.append("This script requires 'Run as Administrator'") + + return ScriptGenerateResponse( + id=generation.id, + script=rendered_script, + warnings=warnings, + metadata={ + "template_name": template.name, + "template_version": template.version, + "requires_elevation": template.requires_elevation, + "requires_modules": template.requires_modules, + "generated_at": generation.created_at.isoformat(), + "estimated_runtime": template.estimated_runtime, + }, + ) + + +# ── Generations history ─────────────────────────────────────────────────── + + +@router.get("/generations", response_model=list[ScriptGenerationRecord]) +async def list_generations( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], + limit: int = Query(20, ge=1, le=100), + offset: int = Query(0, ge=0), +) -> list[ScriptGenerationRecord]: + result = await db.execute( + select(ScriptGeneration, ScriptTemplate.name) + .join(ScriptTemplate, ScriptGeneration.template_id == ScriptTemplate.id) + .where(ScriptGeneration.user_id == current_user.id) + .order_by(ScriptGeneration.created_at.desc()) + .limit(limit) + .offset(offset) + ) + rows = result.all() + return [ + ScriptGenerationRecord( + id=gen.id, + template_id=gen.template_id, + template_name=name, + parameters_used=gen.parameters_used, + created_at=gen.created_at, + ) + for gen, name in rows + ] diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 5e789ff9..13293cca 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -16,6 +16,7 @@ from app.api.endpoints import tree_transfer from app.api.endpoints import ai_suggestions from app.api.endpoints import kb_accelerator from app.api.endpoints import beta_signup +from app.api.endpoints import scripts api_router = APIRouter() @@ -56,3 +57,4 @@ api_router.include_router(tree_transfer.router) api_router.include_router(ai_suggestions.router) api_router.include_router(kb_accelerator.router) api_router.include_router(beta_signup.router) +api_router.include_router(scripts.router) diff --git a/backend/tests/test_scripts.py b/backend/tests/test_scripts.py new file mode 100644 index 00000000..544ad74e --- /dev/null +++ b/backend/tests/test_scripts.py @@ -0,0 +1,336 @@ +"""Integration tests for Script Generator API endpoints.""" +import json +import uuid +from datetime import datetime, timezone + +import pytest +import sqlalchemy as sa + + +# ── Fixtures ────────────────────────────────────────────────────────────── + +@pytest.fixture +async def seed_script_data(test_db): + """Seed script categories and templates into the test database.""" + now = datetime.now(timezone.utc) + cat_id = uuid.UUID("00000000-0000-0000-0000-000000000001") + + # Insert category + await test_db.execute( + sa.text(""" + INSERT INTO script_categories (id, name, slug, description, icon, sort_order, is_active, created_at, updated_at) + VALUES (:id, :name, :slug, :description, :icon, :sort_order, true, :now, :now) + """), + { + "id": cat_id, + "name": "Active Directory", + "slug": "active-directory", + "description": "User account and group management scripts", + "icon": "shield-check", + "sort_order": 1, + "now": now, + }, + ) + + # Minimal template data for testing + templates = [ + { + "id": uuid.UUID("00000000-0000-0000-0001-000000000001"), + "slug": "create-ad-user", + "name": "Create AD User Account", + "description": "Creates a new Active Directory user account.", + "script_body": "$SamAccountName = '{{ sam_account_name }}'", + "parameters_schema": json.dumps({ + "parameters": [ + {"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "order": 1}, + ] + }), + "complexity": "intermediate", + "estimated_runtime": "< 5 seconds", + "requires_elevation": True, + "tags": json.dumps(["active-directory", "user-management"]), + }, + { + "id": uuid.UUID("00000000-0000-0000-0001-000000000002"), + "slug": "disable-ad-user", + "name": "Disable AD User Account", + "description": "Disables an Active Directory user account.", + "script_body": "$SamAccountName = '{{ sam_account_name }}'", + "parameters_schema": json.dumps({ + "parameters": [ + {"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "order": 1}, + ] + }), + "complexity": "beginner", + "estimated_runtime": "< 5 seconds", + "requires_elevation": True, + "tags": json.dumps(["active-directory", "offboarding"]), + }, + { + "id": uuid.UUID("00000000-0000-0000-0001-000000000003"), + "slug": "reset-ad-password", + "name": "Reset AD Password", + "description": "Resets an Active Directory user password.", + "script_body": "$SamAccountName = '{{ sam_account_name }}'\n$NewPassword = {{ new_password | as_secure_string }}\n$ForceChange = {{ force_change_at_logon | as_bool }}\n$UnlockAccount = {{ unlock_account | as_bool }}", + "parameters_schema": json.dumps({ + "parameters": [ + {"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "order": 1}, + {"key": "new_password", "label": "New Password", "type": "password", "required": True, "order": 2, "sensitive": True}, + {"key": "force_change_at_logon", "label": "Force Change at Next Logon", "type": "boolean", "required": True, "order": 3}, + {"key": "unlock_account", "label": "Unlock Account if Locked", "type": "boolean", "required": True, "order": 4}, + ] + }), + "complexity": "beginner", + "estimated_runtime": "< 5 seconds", + "requires_elevation": True, + "tags": json.dumps(["active-directory", "password"]), + }, + { + "id": uuid.UUID("00000000-0000-0000-0001-000000000004"), + "slug": "unlock-ad-account", + "name": "Unlock AD Account", + "description": "Unlocks a locked-out Active Directory user account.", + "script_body": "$SamAccountName = '{{ sam_account_name }}'\n$ShowLockoutInfo = {{ show_lockout_info | as_bool }}", + "parameters_schema": json.dumps({ + "parameters": [ + {"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "order": 1}, + {"key": "show_lockout_info", "label": "Show Lockout Source Info", "type": "boolean", "required": False, "order": 2}, + ] + }), + "complexity": "beginner", + "estimated_runtime": "< 5 seconds", + "requires_elevation": True, + "tags": json.dumps(["active-directory", "lockout"]), + }, + { + "id": uuid.UUID("00000000-0000-0000-0001-000000000005"), + "slug": "delete-ad-user", + "name": "Delete AD User Account", + "description": "Permanently deletes an Active Directory user account.", + "script_body": "$SamAccountName = '{{ sam_account_name }}'\n$ConfirmDeletion = {{ confirm_deletion | as_bool }}", + "parameters_schema": json.dumps({ + "parameters": [ + {"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "order": 1}, + {"key": "confirm_deletion", "label": "Confirm Deletion", "type": "boolean", "required": True, "order": 2}, + ] + }), + "complexity": "advanced", + "estimated_runtime": "< 10 seconds", + "requires_elevation": True, + "tags": json.dumps(["active-directory", "destructive"]), + }, + { + "id": uuid.UUID("00000000-0000-0000-0001-000000000006"), + "slug": "bulk-user-import", + "name": "Bulk User Import from CSV", + "description": "Imports multiple Active Directory user accounts from a CSV file.", + "script_body": "$CSVPath = '{{ csv_path }}'\n$OUPath = '{{ ou_path }}'", + "parameters_schema": json.dumps({ + "parameters": [ + {"key": "csv_path", "label": "CSV File Path", "type": "text", "required": True, "order": 1}, + {"key": "ou_path", "label": "Target OU", "type": "text", "required": True, "order": 2}, + ] + }), + "complexity": "advanced", + "estimated_runtime": "1-2 minutes", + "requires_elevation": True, + "tags": json.dumps(["active-directory", "bulk"]), + }, + ] + + for tmpl in templates: + await test_db.execute( + sa.text(""" + INSERT INTO script_templates ( + id, category_id, name, slug, description, + script_body, parameters_schema, default_values, validation_rules, + tags, complexity, estimated_runtime, requires_elevation, + requires_modules, version, is_verified, is_active, usage_count, + created_at, updated_at + ) VALUES ( + :id, :category_id, :name, :slug, :description, + :script_body, CAST(:parameters_schema AS jsonb), '{}'::jsonb, '{}'::jsonb, + CAST(:tags AS jsonb), :complexity, :estimated_runtime, :requires_elevation, + '[]'::jsonb, 1, true, true, 0, + :now, :now + ) + """), + {**tmpl, "category_id": cat_id, "now": now}, + ) + + await test_db.commit() + return cat_id + + +# ── Categories ──────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_list_categories_requires_auth(client): + response = await client.get("/api/v1/scripts/categories") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_list_categories_returns_seeded_data(client, auth_headers, seed_script_data): + response = await client.get("/api/v1/scripts/categories", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert any(c["slug"] == "active-directory" for c in data) + + +# ── Templates ───────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_list_templates_requires_auth(client): + response = await client.get("/api/v1/scripts/templates") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_list_templates_returns_seeded_data(client, auth_headers, seed_script_data): + response = await client.get("/api/v1/scripts/templates", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert len(data) == 6 + slugs = [t["slug"] for t in data] + assert "create-ad-user" in slugs + assert "reset-ad-password" in slugs + + +@pytest.mark.asyncio +async def test_list_templates_filter_by_category(client, auth_headers, seed_script_data): + response = await client.get( + "/api/v1/scripts/templates?category_slug=active-directory", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert len(data) == 6 + + +@pytest.mark.asyncio +async def test_list_templates_search(client, auth_headers, seed_script_data): + response = await client.get( + "/api/v1/scripts/templates?search=password", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert any("password" in t["name"].lower() or "password" in t["slug"] for t in data) + + +@pytest.mark.asyncio +async def test_get_template_detail(client, auth_headers, seed_script_data): + list_resp = await client.get("/api/v1/scripts/templates", headers=auth_headers) + templates = list_resp.json() + template_id = templates[0]["id"] + + response = await client.get(f"/api/v1/scripts/templates/{template_id}", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert "script_body" in data + assert "parameters_schema" in data + + +@pytest.mark.asyncio +async def test_get_template_detail_not_found(client, auth_headers): + response = await client.get( + "/api/v1/scripts/templates/00000000-0000-0000-0000-000000000099", + headers=auth_headers, + ) + assert response.status_code == 404 + + +# ── Generate ────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_generate_script_success(client, auth_headers, seed_script_data): + list_resp = await client.get( + "/api/v1/scripts/templates?search=unlock", + headers=auth_headers, + ) + unlock_template = list_resp.json()[0] + + response = await client.post( + "/api/v1/scripts/generate", + json={ + "template_id": unlock_template["id"], + "parameters": {"sam_account_name": "jsmith", "show_lockout_info": False}, + }, + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert "script" in data + assert "jsmith" in data["script"] + assert "id" in data + + +@pytest.mark.asyncio +async def test_generate_script_missing_required_param(client, auth_headers, seed_script_data): + list_resp = await client.get( + "/api/v1/scripts/templates?search=unlock", + headers=auth_headers, + ) + unlock_template = list_resp.json()[0] + + response = await client.post( + "/api/v1/scripts/generate", + json={ + "template_id": unlock_template["id"], + "parameters": {}, + }, + headers=auth_headers, + ) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_generate_script_password_redacted_in_record(client, auth_headers, seed_script_data): + list_resp = await client.get( + "/api/v1/scripts/templates?search=reset-ad-password", + headers=auth_headers, + ) + reset_template = list_resp.json()[0] + + await client.post( + "/api/v1/scripts/generate", + json={ + "template_id": reset_template["id"], + "parameters": { + "sam_account_name": "jsmith", + "new_password": "SuperSecret123!", + "force_change_at_logon": True, + "unlock_account": True, + }, + }, + headers=auth_headers, + ) + + history_resp = await client.get("/api/v1/scripts/generations", headers=auth_headers) + assert history_resp.status_code == 200 + generations = history_resp.json() + assert len(generations) > 0 + latest = generations[0] + assert latest["parameters_used"].get("new_password") == "[REDACTED]" + + +# ── Team template CRUD ──────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_create_team_template_requires_team_admin(client, auth_headers, seed_script_data): + list_resp = await client.get("/api/v1/scripts/categories", headers=auth_headers) + cat_id = list_resp.json()[0]["id"] + + response = await client.post( + "/api/v1/scripts/templates", + json={ + "category_id": cat_id, + "name": "My Custom Script", + "script_body": "Write-Host 'hello'", + "parameters_schema": {}, + }, + headers=auth_headers, # regular engineer + ) + assert response.status_code == 403 -- 2.49.1 From e875d73c0b6311cc414a33569b360465561b8f61 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 13 Mar 2026 01:05:39 -0400 Subject: [PATCH 06/78] docs: add Script Generator Phase 2 frontend design spec Co-Authored-By: Claude Sonnet 4.6 --- ...26-03-13-script-generator-phase2-design.md | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-13-script-generator-phase2-design.md diff --git a/docs/superpowers/specs/2026-03-13-script-generator-phase2-design.md b/docs/superpowers/specs/2026-03-13-script-generator-phase2-design.md new file mode 100644 index 00000000..7f45c1b5 --- /dev/null +++ b/docs/superpowers/specs/2026-03-13-script-generator-phase2-design.md @@ -0,0 +1,281 @@ +# Script Generator Phase 2 — Frontend Design + +**Date:** 2026-03-13 +**Status:** Approved +**Phase:** 2 of Script Generator feature +**Builds on:** Phase 1 backend (`feat/script-generator` PR #105) + +--- + +## Goal + +Build the Script Library frontend: a three-panel page where MSP engineers can browse PowerShell script templates by category, fill in parameters, get a live preview, and generate + copy/download the final script. + +--- + +## Architecture + +**Layout:** Top filter bar + two-column layout (C). Category tabs and search across the top, template list on the left, generator panel on the right. Follows the existing glassmorphism design system. + +**State:** Zustand store (`scriptGeneratorStore`) chosen over a local hook because script generation will soon be embeddable in session execution (Script Output Node). A store allows any component anywhere in the tree to read/write generation state without prop drilling. Survives navigation. + +**Preview:** Client-side lightweight substitution for live preview as the user types (`{{variable_name}}` replacement only). The real `POST /scripts/generate` endpoint is called on Generate — it applies filters (`as_secure_string`, `as_array`, `as_bool`), conditionals, and PowerShell-safe sanitization. + +--- + +## Zustand Store — `scriptGeneratorStore` + +**File:** `frontend/src/store/scriptGeneratorStore.ts` + +### State shape + +```typescript +interface ScriptGeneratorState { + // Template browsing + categories: ScriptCategoryResponse[]; + templates: ScriptTemplateListItem[]; + selectedTemplate: ScriptTemplateDetail | null; + searchQuery: string; + activeCategoryId: string | null; + isLoadingTemplates: boolean; + + // Form + paramValues: Record; // keyed by variable_name + formErrors: Record; // keyed by variable_name + + // Output + generatedScript: string | null; + generationId: string | null; + isGenerating: boolean; + generateError: string | null; + + // Actions + loadCategories: () => Promise; + loadTemplates: () => Promise; + selectTemplate: (id: string) => Promise; + setCategory: (id: string | null) => void; + setSearch: (query: string) => void; + setParamValue: (variableName: string, value: string) => void; + generate: (sessionId?: string) => Promise; + clearOutput: () => void; + reset: () => void; +} +``` + +### Behaviour notes + +- `setCategory` and `setSearch` both call `loadTemplates()` after updating state — they compose (category + search both applied to the API call) +- `selectTemplate` fetches the full `ScriptTemplateDetail` (including `parameters_schema` and `script_template`), then calls `reset()` to clear previous form/output state +- `reset()` clears `paramValues`, `formErrors`, `generatedScript`, `generationId`, `generateError` — does not clear template selection or browsing state +- `generate()` validates required params client-side first (populates `formErrors`), then calls `POST /scripts/generate` + +--- + +## Types + +Added to `frontend/src/types/index.ts`: + +```typescript +export interface ScriptCategoryResponse { + id: string; + name: string; + slug: string; + description: string | null; + template_count: number; +} + +export interface ScriptTemplateListItem { + id: string; + category_id: string; + name: string; + description: string | null; + script_complexity: 'simple' | 'moderate' | 'complex'; + usage_count: number; + tags: string[]; +} + +export interface ScriptParameter { + variable_name: string; + label: string; + field_type: 'text' | 'password' | 'select' | 'multiselect' | 'checkbox' | 'number'; + required: boolean; + default_value?: string; + options?: string[]; + is_sensitive: boolean; + display_order: number; + help_text?: string; +} + +export interface ScriptParametersSchema { + parameters: ScriptParameter[]; +} + +export interface ScriptTemplateDetail extends ScriptTemplateListItem { + parameters_schema: ScriptParametersSchema; + script_template: string; +} + +export interface ScriptGenerateRequest { + template_id: string; + parameters: Record; + session_id?: string; +} + +export interface ScriptGenerateResponse { + generation_id: string; + generated_script: string; + template_name: string; +} +``` + +--- + +## API Client + +**File:** `frontend/src/api/scripts.ts` + +```typescript +getCategories(): Promise +getTemplates(params?: { category_id?: string; search?: string }): Promise +getTemplateDetail(id: string): Promise +generate(req: ScriptGenerateRequest): Promise +getGenerations(): Promise +``` + +All methods use the existing `apiClient` (base URL `/api/v1`, auth interceptor handles token refresh). + +Exported from `frontend/src/api/index.ts` as `scriptsApi`. + +--- + +## Component Tree + +``` +ScriptLibraryPage pages/ScriptLibraryPage.tsx +├── ScriptFilterBar components/scripts/ScriptFilterBar.tsx +├── ScriptTemplateList components/scripts/ScriptTemplateList.tsx +│ └── TemplateCard components/scripts/TemplateCard.tsx +└── ScriptGeneratorPanel components/scripts/ScriptGeneratorPanel.tsx + ├── ScriptParameterForm components/scripts/ScriptParameterForm.tsx + │ └── ScriptParameterField components/scripts/ScriptParameterField.tsx + └── ScriptPreview components/scripts/ScriptPreview.tsx + └── PowerShellHighlighter components/scripts/PowerShellHighlighter.tsx +``` + +### Component responsibilities + +**`ScriptLibraryPage`** +- Bootstraps store on mount: calls `loadCategories()` then `loadTemplates()` +- Renders two-column layout (template list left, generator panel right) +- Renders `ScriptFilterBar` above the two columns +- No business logic — pure layout + bootstrap + +**`ScriptFilterBar`** +- Category tabs (pill style, `bg-primary/10` + cyan accent on active) +- Search input (debounced 300ms) — searches names, descriptions, and tags (backend handles it) +- Reads `categories`, `activeCategoryId`, `searchQuery` from store +- Calls `setCategory` and `setSearch` + +**`ScriptTemplateList`** +- Scrollable list of `TemplateCard` components +- Reads `templates`, `isLoadingTemplates`, `selectedTemplate` from store +- Shows skeleton loaders while loading +- Shows empty state when no templates match + +**`TemplateCard`** +- Displays: name, description (truncated to 2 lines), complexity badge, usage count, tags +- Active state: `bg-primary/10` background + 3px left cyan accent bar (matches existing nav active pattern) +- Calls `selectTemplate(template.id)` on click +- Complexity badge colors: simple → emerald-400, moderate → amber-400, complex → rose-500 + +**`ScriptGeneratorPanel`** +- Shows placeholder ("Select a template to get started") when `selectedTemplate` is null +- When template selected: renders template name/description header, `ScriptParameterForm`, `ScriptPreview`, and action bar +- Action bar: Generate button (`bg-gradient-brand`) + Download `.ps1` button +- Download triggers `new Blob([generatedScript], { type: 'text/plain' })` → anchor click +- Shows `generateError` inline below Generate button +- Viewers see Generate button disabled with tooltip "Engineer access required" (checked via `usePermissions()`) + +**`ScriptParameterForm`** +- Iterates `selectedTemplate.parameters_schema.parameters` sorted by `display_order` +- Renders a `ScriptParameterField` per parameter +- Client-side required validation on Generate (marks `formErrors` in store) +- Disabled entirely for viewers + +**`ScriptParameterField`** +- Renders input by `field_type`: text → ``, password → ``, select → ``, number → `` +- Shows `help_text` as a small muted line below the field +- Shows `formErrors[variable_name]` as an inline error +- Password fields show a show/hide toggle +- Calls `setParamValue(variable_name, value)` on change + +**`ScriptPreview`** +- Two modes: + - **Draft mode** (before Generate): client-side substitution of `{{variable_name}}` in `selectedTemplate.script_template` using current `paramValues`. Unfilled params render as `{{variable_name}}` placeholders (visually dimmed). + - **Generated mode** (after Generate): shows `generatedScript` from store +- Copy icon (Lucide `Copy`) in top-right corner of code block — calls `navigator.clipboard.writeText()`; shows a brief "Copied!" tooltip on success +- Passes script string to `PowerShellHighlighter` + +**`PowerShellHighlighter`** +- Pure component: `({ script: string }) => JSX.Element` +- Regex-based syntax highlighting (no external library): + - Comments (`#...`) → `text-[#8b949e]` + - Cmdlets (`Verb-Noun` pattern) → `text-[#22d3ee]` (cyan) + - String literals (`"..."`, `'...'`) → `text-[#a5d6ff]` + - Variables (`$VarName`) → `text-[#79c0ff]` + - Parameters (`-ParamName`) → `text-[#d2a8ff]` (purple) + - Keywords (`if`, `foreach`, `function`, etc.) → `text-[#ff7b72]` + - Unfilled placeholders (`{{variable_name}}`) → `text-amber-400` with dashed underline +- Renders as `
` with `font-label` (JetBrains Mono), `bg-card`, rounded corners
+
+---
+
+## Routing & Navigation
+
+- Route: `/scripts` added to `frontend/src/router.tsx` inside the `ProtectedRoute`/`AppLayout` children
+- Sidebar nav entry: "Scripts" with a `Terminal` icon (Lucide), grouped under the main nav
+- No sub-routes needed for Phase 2
+
+---
+
+## Permissions
+
+| Action | Minimum role |
+|--------|-------------|
+| View Script Library page | Any authenticated user |
+| Browse templates, see preview | Any authenticated user |
+| Fill form, generate, copy, download | Engineer or above |
+
+Implemented via `usePermissions()` hook. Viewer-blocking applied to: `ScriptParameterForm` (disabled), Generate button (disabled + tooltip), Download button (disabled + tooltip).
+
+---
+
+## Search Behaviour
+
+- Search query sent as `?search=` param to `GET /scripts/templates`
+- Backend searches name, description, and tags (already implemented in Phase 1)
+- Debounced 300ms in `ScriptFilterBar` before calling `setSearch`
+- Category filter and search compose: both params sent simultaneously
+
+---
+
+## Empty & Loading States
+
+| Scenario | Treatment |
+|----------|-----------|
+| Templates loading | Skeleton cards (3 placeholder TemplateCards) |
+| No templates in category | Illustration + "No templates found" message |
+| No search results | "No templates match your search" with clear button |
+| No template selected | Right panel: centered placeholder with Terminal icon + "Select a template to get started" |
+| Generating | Generate button shows spinner, disabled |
+| Generate error | Inline error text below Generate button in rose-500 |
+
+---
+
+## Out of Scope (Phase 2)
+
+- Session-embedded script generation (Script Output Node) — Phase 3
+- Template creation/editing UI — admin-only, deferred
+- Generation history page — deferred
+- Admin template management — deferred
+- Script execution / RMM integration — long-term roadmap
-- 
2.49.1


From 9a37caf596b6c75bdd4a765f8bd6b5321d857f7d Mon Sep 17 00:00:00 2001
From: chihlasm 
Date: Fri, 13 Mar 2026 01:26:24 -0400
Subject: [PATCH 07/78] =?UTF-8?q?docs:=20update=20Phase=202=20spec=20after?=
 =?UTF-8?q?=20review=20loop=20=E2=80=94=20fix=20field=20names,=20permissio?=
 =?UTF-8?q?ns,=20search=20coordination?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-Authored-By: Claude Sonnet 4.6 
---
 ...26-03-13-script-generator-phase2-design.md | 305 ++++++++++++------
 1 file changed, 214 insertions(+), 91 deletions(-)

diff --git a/docs/superpowers/specs/2026-03-13-script-generator-phase2-design.md b/docs/superpowers/specs/2026-03-13-script-generator-phase2-design.md
index 7f45c1b5..0008ee63 100644
--- a/docs/superpowers/specs/2026-03-13-script-generator-phase2-design.md
+++ b/docs/superpowers/specs/2026-03-13-script-generator-phase2-design.md
@@ -9,17 +9,21 @@
 
 ## Goal
 
-Build the Script Library frontend: a three-panel page where MSP engineers can browse PowerShell script templates by category, fill in parameters, get a live preview, and generate + copy/download the final script.
+Build the Script Library frontend: a page where MSP engineers can browse PowerShell script templates by category, fill in parameters, get a live preview, and generate + copy/download the final script.
 
 ---
 
 ## Architecture
 
-**Layout:** Top filter bar + two-column layout (C). Category tabs and search across the top, template list on the left, generator panel on the right. Follows the existing glassmorphism design system.
+**Layout:** Top filter bar + two-column layout. Category tabs and search across the top, template list on the left, generator panel on the right. Follows the existing glassmorphism design system.
 
-**State:** Zustand store (`scriptGeneratorStore`) chosen over a local hook because script generation will soon be embeddable in session execution (Script Output Node). A store allows any component anywhere in the tree to read/write generation state without prop drilling. Survives navigation.
+**State:** Zustand store (`scriptGeneratorStore`) chosen over a local hook because script generation will soon be embeddable in session execution (Script Output Node). A store allows any component anywhere in the tree to read/write generation state without prop drilling. Survives navigation — stale state is intentional and supports Phase 3 embeddability. `reset()` is a public action exposed for Phase 3 callers (e.g. session execution context that needs to clear output between runs). Within Phase 2, it is never called automatically; `selectTemplate()` clears form/output state inline via its own atomic `set()` call without calling `reset()`.
 
-**Preview:** Client-side lightweight substitution for live preview as the user types (`{{variable_name}}` replacement only). The real `POST /scripts/generate` endpoint is called on Generate — it applies filters (`as_secure_string`, `as_array`, `as_bool`), conditionals, and PowerShell-safe sanitization.
+**Preview:** Client-side lightweight substitution for live preview as the user types (`{{key}}` replacement only, matching the backend template variable syntax). The real `POST /scripts/generate` endpoint is called on Generate — it applies filters, conditionals, and PowerShell-safe sanitization on the backend.
+
+**Security note:** The viewer restriction on Generate/Download is enforced frontend-only in Phase 2. The backend `POST /scripts/generate` endpoint uses only `get_current_active_user` with no role check. Adding a backend engineer role guard is deferred — this is a known limitation.
+
+**Search note:** The backend `?search=` parameter matches against `name`, `description`, and `slug`. Tags are filtered separately via `?tags=` (comma-separated). Phase 2 does not expose a tag filter UI — search covers name/description/slug only.
 
 ---
 
@@ -36,16 +40,18 @@ interface ScriptGeneratorState {
   templates: ScriptTemplateListItem[];
   selectedTemplate: ScriptTemplateDetail | null;
   searchQuery: string;
-  activeCategoryId: string | null;
-  isLoadingTemplates: boolean;
+  activeCategoryId: string | null;    // null = "All"; used to derive slug for API calls
+  isLoadingTemplates: boolean;        // drives skeleton in ScriptTemplateList only
+  isLoadingDetail: boolean;           // drives spinner in ScriptGeneratorPanel only
 
   // Form
-  paramValues: Record;       // keyed by variable_name
-  formErrors: Record;        // keyed by variable_name
+  paramValues: Record;   // keyed by ScriptParameter.key; booleans stored as 'true'/'false'
+  formErrors: Record;    // keyed by ScriptParameter.key
 
   // Output
   generatedScript: string | null;
   generationId: string | null;
+  generationWarnings: string[];
   isGenerating: boolean;
   generateError: string | null;
 
@@ -55,7 +61,8 @@ interface ScriptGeneratorState {
   selectTemplate: (id: string) => Promise;
   setCategory: (id: string | null) => void;
   setSearch: (query: string) => void;
-  setParamValue: (variableName: string, value: string) => void;
+  setParamValue: (key: string, value: string) => void;
+  validate: () => boolean;
   generate: (sessionId?: string) => Promise;
   clearOutput: () => void;
   reset: () => void;
@@ -64,10 +71,18 @@ interface ScriptGeneratorState {
 
 ### Behaviour notes
 
-- `setCategory` and `setSearch` both call `loadTemplates()` after updating state — they compose (category + search both applied to the API call)
-- `selectTemplate` fetches the full `ScriptTemplateDetail` (including `parameters_schema` and `script_template`), then calls `reset()` to clear previous form/output state
-- `reset()` clears `paramValues`, `formErrors`, `generatedScript`, `generationId`, `generateError` — does not clear template selection or browsing state
-- `generate()` validates required params client-side first (populates `formErrors`), then calls `POST /scripts/generate`
+- `setCategory` updates `activeCategoryId`, then calls `loadTemplates()`
+- `setSearch` updates `searchQuery`, then calls `loadTemplates()`. The component (not the store) debounces its call to `setSearch` — `setSearch` always calls `loadTemplates()` immediately on invocation
+- `loadTemplates()` resolves the slug from the `categories` array (`categories.find(c => c.id === activeCategoryId)?.slug`) before sending `{ category_slug, search }` to the API. When `activeCategoryId` is `null` ("All"), `category_slug` is omitted from the request
+- `selectTemplate(id)` workflow:
+  1. Sets `isLoadingDetail: true` (does NOT touch `isLoadingTemplates`)
+  2. Fetches `ScriptTemplateDetail` from the API
+  3. In a single `set()` call: sets `selectedTemplate`, populates `paramValues` by converting each `parameter.default` to string (`null` → `''`, `true` → `'true'`, `false` → `'false'`, numbers → `String(n)`), clears `formErrors`, `generatedScript`, `generationId`, `generationWarnings`, `generateError`, sets `isLoadingDetail: false`
+  4. The template list remains fully visible and interactive throughout
+- `reset()` clears exactly: `paramValues`, `formErrors`, `generatedScript`, `generationId`, `generationWarnings`, `generateError`. Does NOT clear `selectedTemplate`, `categories`, `templates`, or any browsing state. Not called by `selectTemplate()` — that action handles its own inline clear. Exposed as a public store action for Phase 3 callers
+- `validate()`: if `selectedTemplate` is `null`, returns `true` immediately (nothing to validate). Otherwise iterates `selectedTemplate.parameters_schema.parameters`, checks `required && !paramValues[key]` for each, writes errors to `formErrors` via `set()`, returns `false` if any required param is missing, `true` otherwise
+- `generate(sessionId?)`: if `selectedTemplate` is `null`, returns immediately (no-op). Otherwise calls `validate()` first — if it returns `false`, stops (errors are already in store). If valid, sets `isGenerating: true`, clears `generateError`, calls `POST /scripts/generate`. On success: sets `generatedScript`/`generationId`/`generationWarnings`, sets `isGenerating: false`. On error: extracts `error.response?.data?.detail` (FastAPI detail string) or falls back to `'Failed to generate script'`, sets `generateError`, sets `isGenerating: false`
+- On generate success: `generatedScript` = `response.script`, `generationId` = `response.id`, `generationWarnings` = `response.warnings`
 
 ---
 
@@ -81,29 +96,53 @@ export interface ScriptCategoryResponse {
   name: string;
   slug: string;
   description: string | null;
+  icon: string | null;
+  sort_order: number;
   template_count: number;
 }
 
 export interface ScriptTemplateListItem {
   id: string;
   category_id: string;
+  team_id: string | null;
   name: string;
+  slug: string;
   description: string | null;
-  script_complexity: 'simple' | 'moderate' | 'complex';
-  usage_count: number;
   tags: string[];
+  complexity: 'beginner' | 'intermediate' | 'advanced'; // must match backend ScriptComplexity enum exactly
+  estimated_runtime: string | null;
+  requires_elevation: boolean;
+  requires_modules: string[];
+  is_verified: boolean;
+  usage_count: number;
+}
+
+export interface ScriptParameterOption {
+  value: string;
+  label: string;
+}
+
+export interface ScriptParameterValidation {
+  min_value?: number;   // matches backend field name (not 'min')
+  max_value?: number;   // matches backend field name (not 'max')
+  pattern?: string;
+  min_length?: number;
+  max_length?: number;
 }
 
 export interface ScriptParameter {
-  variable_name: string;
+  key: string;
   label: string;
-  field_type: 'text' | 'password' | 'select' | 'multiselect' | 'checkbox' | 'number';
+  type: 'text' | 'password' | 'select' | 'boolean' | 'multi_text' | 'number' | 'textarea';
   required: boolean;
-  default_value?: string;
-  options?: string[];
-  is_sensitive: boolean;
-  display_order: number;
-  help_text?: string;
+  placeholder: string | null;
+  group: string | null;
+  order: number;
+  help_text: string | null;
+  options: ScriptParameterOption[] | null; // for select type: [{value, label}]
+  default: string | boolean | number | null;
+  validation: ScriptParameterValidation | null;
+  sensitive: boolean;
 }
 
 export interface ScriptParametersSchema {
@@ -111,20 +150,45 @@ export interface ScriptParametersSchema {
 }
 
 export interface ScriptTemplateDetail extends ScriptTemplateListItem {
+  use_case: string | null;
+  script_body: string;
+  // NOTE: backend types this as `dict` — FastAPI serializes it as plain JSON.
+  // At runtime this arrives as `unknown`. Access via a helper:
+  //   function getParameters(detail: ScriptTemplateDetail): ScriptParameter[] {
+  //     return ((detail.parameters_schema as ScriptParametersSchema)?.parameters ?? [])
+  //   }
   parameters_schema: ScriptParametersSchema;
-  script_template: string;
+  default_values: Record;
+  validation_rules: Record;
+  version: number;
+  created_at: string;
+  updated_at: string;
 }
 
 export interface ScriptGenerateRequest {
   template_id: string;
-  parameters: Record;
+  parameters: Record;
   session_id?: string;
 }
 
 export interface ScriptGenerateResponse {
-  generation_id: string;
-  generated_script: string;
+  id: string;        // generation UUID
+  script: string;    // rendered PowerShell
+  warnings: string[];
+  metadata: {
+    template_name: string;
+    template_version: number;
+    requires_elevation: boolean;
+    [key: string]: unknown;
+  };
+}
+
+export interface ScriptGenerationRecord {
+  id: string;
+  template_id: string;
   template_name: string;
+  parameters_used: Record; // sensitive values already redacted by backend
+  created_at: string;
 }
 ```
 
@@ -132,25 +196,34 @@ export interface ScriptGenerateResponse {
 
 ## API Client
 
-**File:** `frontend/src/api/scripts.ts`
+**File:** `frontend/src/api/scripts.ts` — use a named export object (matching the `copilotApi`/`assistantChatApi` pattern in `api/index.ts`):
 
 ```typescript
-getCategories(): Promise
-getTemplates(params?: { category_id?: string; search?: string }): Promise
-getTemplateDetail(id: string): Promise
-generate(req: ScriptGenerateRequest): Promise
-getGenerations(): Promise
+// scripts.ts
+export const scriptsApi = {
+  getCategories(): Promise { ... },
+  getTemplates(params?: { category_slug?: string; search?: string; tags?: string }): Promise { ... },
+  getTemplateDetail(id: string): Promise { ... },
+  generate(req: ScriptGenerateRequest): Promise { ... },
+  getGenerations(): Promise { ... },
+}
+```
+
+Re-exported from `frontend/src/api/index.ts` as:
+
+```typescript
+export { scriptsApi } from './scripts'
 ```
 
 All methods use the existing `apiClient` (base URL `/api/v1`, auth interceptor handles token refresh).
 
-Exported from `frontend/src/api/index.ts` as `scriptsApi`.
+> `getGenerations()` and the `tags` param on `getTemplates` are Phase 3 scaffolding — included because the backend endpoints already exist and adding them later would require touching the same file. Neither is called by the Phase 2 UI. Mark both with a `// Phase 3` comment in the implementation.
 
 ---
 
 ## Component Tree
 
-```
+```text
 ScriptLibraryPage                    pages/ScriptLibraryPage.tsx
 ├── ScriptFilterBar                  components/scripts/ScriptFilterBar.tsx
 ├── ScriptTemplateList               components/scripts/ScriptTemplateList.tsx
@@ -165,75 +238,116 @@ ScriptLibraryPage                    pages/ScriptLibraryPage.tsx
 ### Component responsibilities
 
 **`ScriptLibraryPage`**
+
 - Bootstraps store on mount: calls `loadCategories()` then `loadTemplates()`
-- Renders two-column layout (template list left, generator panel right)
-- Renders `ScriptFilterBar` above the two columns
-- No business logic — pure layout + bootstrap
+- Owns `inputValue` state (search input text) and `onClearSearch` callback — lifted here so `ScriptFilterBar` and `ScriptTemplateList` can coordinate clear-search without direct coupling
+- Renders `ScriptFilterBar` (passing `inputValue`, `setInputValue`, `onClearSearch`) above the two columns
+- Renders two-column layout: `ScriptTemplateList` (left, passing `inputValue` + `onClearSearch`) and `ScriptGeneratorPanel` (right)
+- Minimal logic — only the search state lift, everything else in the store or child components
 
 **`ScriptFilterBar`**
-- Category tabs (pill style, `bg-primary/10` + cyan accent on active)
-- Search input (debounced 300ms) — searches names, descriptions, and tags (backend handles it)
-- Reads `categories`, `activeCategoryId`, `searchQuery` from store
-- Calls `setCategory` and `setSearch`
+
+- Renders an "All" tab first (calls `setCategory(null)`, active when `activeCategoryId === null`), followed by one tab per category
+- Category tabs: pill style, `bg-primary/10` + left 3px cyan accent bar on active tab
+- Search: local `inputValue` state controls the `` value (NOT `searchQuery` from store — avoids input lag during debounce). `useEffect` watches `inputValue`, schedules `setTimeout(() => setSearch(inputValue), 300)`, clears timeout on cleanup
+- Reads `categories`, `activeCategoryId` from store; does NOT use `searchQuery` from store to control the input
+- Exposes a `onClearSearch` callback (or reads a `clearSearch` prop) — see `ScriptTemplateList` below for why. Simplest implementation: `ScriptLibraryPage` passes a `clearSearch` callback to both components; `ScriptFilterBar` exposes it as a function ref or the page wires `setInputValue` via a `useRef`
 
 **`ScriptTemplateList`**
+
 - Scrollable list of `TemplateCard` components
 - Reads `templates`, `isLoadingTemplates`, `selectedTemplate` from store
-- Shows skeleton loaders while loading
-- Shows empty state when no templates match
+- Accepts `inputValue: string` and `onClearSearch: () => void` props from `ScriptLibraryPage`
+- Shows 3 skeleton placeholder cards while `isLoadingTemplates` is true
+- Shows "No templates found" empty state when `templates.length === 0` and `!isLoadingTemplates` and `inputValue === ''`
+- Shows "No templates match your search" + "Clear search" button when `templates.length === 0` and `!isLoadingTemplates` and `inputValue !== ''`. "Clear search" calls `onClearSearch()` which resets both `inputValue` in `ScriptFilterBar` and `searchQuery` in the store. Using `inputValue` (not `store.searchQuery`) avoids the 300ms debounce lag in empty-state detection
 
 **`TemplateCard`**
-- Displays: name, description (truncated to 2 lines), complexity badge, usage count, tags
-- Active state: `bg-primary/10` background + 3px left cyan accent bar (matches existing nav active pattern)
+
+- Displays: name, `complexity` badge, `usage_count`, description (2-line clamp via `line-clamp-2`), `tags`
+- Shows a `requires_elevation` warning icon (Lucide `ShieldAlert`, amber-400) if true
+- Active state when `template.id === selectedTemplate?.id`: `bg-primary/10` background + 3px left cyan gradient accent bar
 - Calls `selectTemplate(template.id)` on click
-- Complexity badge colors: simple → emerald-400, moderate → amber-400, complex → rose-500
+- Complexity badge colors: beginner → emerald-400, intermediate → amber-400, advanced → rose-500
+- Slug is already URL/filesystem-safe by backend convention — no sanitization needed
 
 **`ScriptGeneratorPanel`**
-- Shows placeholder ("Select a template to get started") when `selectedTemplate` is null
-- When template selected: renders template name/description header, `ScriptParameterForm`, `ScriptPreview`, and action bar
-- Action bar: Generate button (`bg-gradient-brand`) + Download `.ps1` button
-- Download triggers `new Blob([generatedScript], { type: 'text/plain' })` → anchor click
-- Shows `generateError` inline below Generate button
-- Viewers see Generate button disabled with tooltip "Engineer access required" (checked via `usePermissions()`)
+
+- Shows placeholder (Terminal icon + "Select a template to get started") when `selectedTemplate` is null
+- Shows spinner overlay when `isLoadingDetail` is true (template list remains visible behind it)
+- When template selected and `!isLoadingDetail`: renders template name/description header, `ScriptParameterForm`, `ScriptPreview`, action bar
+- Action bar (visible when template selected):
+  - Generate button (`bg-gradient-brand`) — calls `generate()`; shows spinner + disabled while `isGenerating`
+  - Download `.ps1` button — disabled when `generatedScript` is null; on click triggers `new Blob([generatedScript], { type: 'text/plain' })` → programmatic anchor click with `download="${selectedTemplate.slug}.ps1"`
+- `generateError` shown as rose-500 text inline below action bar
+- `generationWarnings` shown as amber-400 callout above the preview when `generationWarnings.length > 0`
+- Permission check: call `usePermissions()` directly in this component; derive `canGenerate = !isViewer`. Pass `canGenerate` as a prop down to `ScriptParameterForm`. Generate and Download buttons disabled with tooltip "Engineer access required" when `!canGenerate`
 
 **`ScriptParameterForm`**
-- Iterates `selectedTemplate.parameters_schema.parameters` sorted by `display_order`
-- Renders a `ScriptParameterField` per parameter
-- Client-side required validation on Generate (marks `formErrors` in store)
-- Disabled entirely for viewers
+
+- Accepts `canGenerate: boolean` prop from `ScriptGeneratorPanel`
+- Iterates `selectedTemplate.parameters_schema.parameters` sorted by `order`
+- Groups by `group` field: renders a `font-label uppercase text-muted-foreground` section label before each group boundary (parameters with `group: null` rendered ungrouped at the top)
+- Renders a `ScriptParameterField` per parameter, passing `canGenerate` down
+- Does NOT own the Generate button and does NOT call `generate()` or `validate()` directly — validation is triggered from `ScriptGeneratorPanel` via the store's `generate()` action (which calls `validate()` internally)
 
 **`ScriptParameterField`**
-- Renders input by `field_type`: text → ``, password → ``, select → ``, number → ``
-- Shows `help_text` as a small muted line below the field
-- Shows `formErrors[variable_name]` as an inline error
-- Password fields show a show/hide toggle
-- Calls `setParamValue(variable_name, value)` on change
+
+- Accepts `param: ScriptParameter`, `value: string`, `error: string | undefined`, `disabled: boolean`
+- Renders input by `type`. Pass `error` prop to shared components (they render their own error message — do NOT add a separate error `

` below): + - `text` → `` + - `password` → `` with Lucide `Eye`/`EyeOff` toggle + - `textarea` → `