diff --git a/CLAUDE.md b/CLAUDE.md index 4537856d..f16fd884 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,7 @@ - **Rebrand guide:** [REBRAND-IMPLEMENTATION-GUIDE.md](REBRAND-IMPLEMENTATION-GUIDE.md) **Component styling rules:** + - Primary buttons: `bg-gradient-brand` (cyan `135deg`) with `shadow-lg shadow-primary/20`, hover `opacity-0.9`, active `scale(0.97)` - Secondary buttons: `bg-[rgba(255,255,255,0.04)]` with `border-[rgba(255,255,255,0.06)]`, hover brightens border - Active nav items: `bg-primary/10` background + 3px left cyan gradient accent bar @@ -47,6 +48,7 @@ When adding new pages/components: use "ResolutionFlow" branding, ice-cyan gradie - Prefer correct architecture over minimal diff - If two approaches exist, implement the one that scales, not the one that's faster to write - Flag any "simpler approach" tradeoffs for product owner review before proceeding + --- ## Current State @@ -77,6 +79,7 @@ When adding new pages/components: use "ResolutionFlow" branding, ice-cyan gradie ## Tech Stack ### Backend + - **Framework:** Python FastAPI - **Database:** PostgreSQL 16 (async via SQLAlchemy 2.0 + asyncpg) - **Migrations:** Alembic @@ -85,6 +88,7 @@ When adding new pages/components: use "ResolutionFlow" branding, ice-cyan gradie - **Scheduling:** APScheduler 3.x (async, in-process with FastAPI lifespan) + croniter + pytz ### Frontend + - **Framework:** React 19 + Vite + TypeScript - **Styling:** Tailwind CSS v3 — dark-first with purple gradient accents (see Branding section) - **State:** Zustand (with immer + zundo for undo/redo) @@ -131,6 +135,7 @@ patherly/ ## Environment Variables ### Backend (`backend/.env`) + ```bash APP_NAME=ResolutionFlow DEBUG=true @@ -143,12 +148,54 @@ REQUIRE_INVITE_CODE=true ``` ### Frontend (`frontend/.env.local` - optional) + ```bash VITE_API_URL=http://localhost:8000 ``` --- +## ConnectWise PSA Integration + +ResolutionFlow integrates with ConnectWise PSA (formerly Manage) as the primary PSA integration. All ConnectWise API reference materials live in `docs/connectwise/`. + +### Best Practices Documentation + +Official ConnectWise developer guides live in `docs/connectwise/best-practices/`. Read these BEFORE implementing any CW API integration code: + +- `PSA-API-Requests.md` — HTTP methods, response codes, condition query syntax, PATCH format, URL encoding, partial responses, custom fields. READ FIRST. +- `PSA-Callbacks.md` — Callback type/level matrix, retry behavior, URL parameter gotcha, HMAC signature verification. +- `PSA-Pagination.md` — Navigable vs Forward-Only pagination, Link headers, while-loop pattern. +- `PSA-Service-Tickets.md` — Ticket field philosophy, recommended field mappings. +- `PSA-Versioning.md` — Pin API version via Accept header. Use `application/vnd.connectwise.com+json; version=2025.16`. +- `PSA-Cloud-URL-Formatting.md` — Dynamic base URL construction via `/login/companyinfo/{companyId}`. +- `Bundled-Requests.md` — Batch multiple API calls into one request via `/system/bundles`. +- `PSA-Markdown.md` — Ticket notes support markdown. Format session documentation output accordingly. +- `PSA-Company-Synchronization.md` — Filter companies by Status/Type for mapping UI. +- `PSA-Data-Protection.md` — Security role model, request minimal permissions (MY not ALL). + +### Reference Files (read in this order) + +1. `docs/connectwise/CONNECTWISE-API-REFERENCE.md` — Read FIRST. Quick reference covering auth patterns, tiered endpoint map, key field mappings, and integration architecture flows. +2. `docs/connectwise/connectwise-psa-resolutionflow-reference.json` — Extracted OpenAPI 3.0.1 spec (v2025.16) with only the 670 endpoints and 342 schemas relevant to ResolutionFlow. Use for exact field types, request/response shapes, and parameter details. +3. `docs/connectwise/connectwise-psa-openapi-full.json` — Complete ConnectWise PSA OpenAPI spec (1838 endpoints, 842 schemas). Only consult if you need an endpoint outside the extracted subset. + +### Integration Architecture + +- **Session → Ticket Notes:** Post auto-generated session documentation to ConnectWise tickets as internal analysis notes via `POST /service/tickets/{id}/notes` +- **Ticket Context → Session Runner:** Pull ticket details, company info, and attached configurations to give FlowPilot AI real-world context +- **Callbacks:** Register webhooks via `/system/callbacks` for real-time ticket event notifications to suggest relevant Flows + +### Key Implementation Rules + +- Auth: API Key auth (Base64 of `companyId+publicKey:privateKey`) + `clientId` header on every request +- All ConnectWise integration code belongs in a dedicated service layer (e.g., `services/connectwise/`) — do NOT scatter CW API calls across the codebase +- Each MSP tenant provides their own CW credentials — ResolutionFlow stores these per-team, never per-user +- Design for the Autotask integration following the same service layer pattern (future PSA) +- Respect CW API: cache board/status/priority lookups, paginate with max 1000 per page, handle retries gracefully + +--- + ## Development Commands ```powershell @@ -190,11 +237,13 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi ``` ### URLs -- Frontend: http://localhost:5173 -- Backend API: http://localhost:8000 -- API Docs: http://localhost:8000/api/docs + +- Frontend: +- Backend API: +- API Docs: ### Test Users (seeded via `scripts/seed_test_users.py`) + - All share password: `TestPass123!` - `admin@resolutionflow.example.com` (super_admin), `teamadmin@resolutionflow.example.com` (team_admin), `engineer@resolutionflow.example.com` (engineer), `pro@resolutionflow.example.com` (solo pro) @@ -205,6 +254,7 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi ### Top Gotchas (most commonly hit) **1. DateTime Handling — Always timezone-aware:** + ```python # CORRECT created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) @@ -212,6 +262,7 @@ created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezo ``` **2. SQLAlchemy Async — No lazy loading on new objects:** + ```python # WRONG — MissingGreenlet error new_tree = Tree(...); db.add(new_tree); await db.flush() @@ -222,6 +273,7 @@ await db.execute(tree_tag_assignments.insert().values(tree_id=new_tree.id, tag_i ``` **3. React State — Don't store object snapshots:** + ```tsx // WRONG — snapshot won't update const [editingNode, setEditingNode] = useState(node) @@ -231,17 +283,20 @@ const editingNode = editingNodeId ? findNode(editingNodeId, tree?.tree_structure ``` **4. Modal Draft State — Exclude store-managed fields:** + ```tsx const { children, ...draftWithoutChildren } = draft updateNode(node.id, draftWithoutChildren) // Don't overwrite children ``` **5. Multiple FKs to same table — Specify `foreign_keys` on BOTH sides:** + ```python author = relationship("User", foreign_keys=[author_id], back_populates="trees") ``` **6. PostgreSQL NULL in UUID columns:** + ```sql SELECT 'tag', 'slug', NULL::uuid as team_id -- Must cast NULL to uuid ``` @@ -255,6 +310,7 @@ SELECT 'tag', 'slug', NULL::uuid as team_id -- Must cast NULL to uuid **9. Public endpoints with optional auth:** Use manual `_get_optional_user(request, db)` helper, NOT `Optional[User]` param (FastAPI treats it as Pydantic field). **10. React Router — Clear dirty state before navigation:** + ```tsx markSaved() // Clear isDirty BEFORE navigate() navigate(`/trees/${newTree.id}/edit`) @@ -352,6 +408,8 @@ navigate(`/trees/${newTree.id}/edit`) **57. Node field priority for display/context:** Nodes use different label fields by type — procedural steps use `title`+`description`, decision nodes use `question`, action/solution nodes use `title`. When reading a node's label generically, check: `title` → `question` → `description` → `content` → `label`. See `copilot_service.py` `_build_flow_context()`. +**58. `scriptGeneratorStore.generate()` has an optional `sessionId` param:** `generate(sessionId?: string)` — do NOT pass it as a bare `onClick={generate}` handler (TypeScript error: MouseEvent not assignable to string). Always wrap: `onClick={() => generate()}`. + --- ## RBAC & Permissions @@ -408,19 +466,24 @@ navigate(`/trees/${newTree.id}/edit`) ## Coding Standards ### Python + - Type hints everywhere, async/await for DB, Pydantic for validation, `DateTime(timezone=True)` always ### TypeScript + - Interfaces for all data, `const` over `let`, functional components + hooks, reusable logic in custom hooks ### Git + - Format: `type: description` (feat, fix, refactor, docs, test, chore) - Always include `Co-Authored-By: Claude Opus 4.6 ` - Always create feature branch BEFORE committing: `git checkout -b feat/feature-name` - Large features: commit per phase with `npm run build` validation ### After Completing Work + When a feature, fix, or significant piece of work is finished and merged/committed: + 1. **Update `CURRENT-STATE.md`** — move completed items, update "In Progress" and "What's Next" sections 2. **Update `03-DEVELOPMENT-ROADMAP.md`** — check off completed work, update phase status 3. **Close related GitHub Issues** — use `gh issue close #N` for any issues resolved by the work @@ -451,7 +514,7 @@ When a feature, fix, or significant piece of work is finished and merged/committ | What | Where | |------|-------| -| API Docs | http://localhost:8000/api/docs | +| API Docs | | | Detailed Status | [CURRENT-STATE.md](CURRENT-STATE.md) | | Development Roadmap | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) | | GitHub Issues | `gh issue list --state open` | 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/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" +""", + ), + + ] diff --git a/backend/app/api/endpoints/scripts.py b/backend/app/api/endpoints/scripts.py new file mode 100644 index 00000000..5f6ab8df --- /dev/null +++ b/backend/app/api/endpoints/scripts.py @@ -0,0 +1,413 @@ +"""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.core.permissions import can_manage_script_template, can_create_content +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() + + + +# ── 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"), + managed: Optional[bool] = Query(None, description="If true, return only templates this user can edit"), +) -> 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), + ) + ) + + if managed: + if current_user.is_super_admin: + pass # super admin can edit all + elif current_user.account_role == "owner": + query = query.where( + or_( + ScriptTemplate.created_by == current_user.id, + ScriptTemplate.team_id != None, # noqa: E711 + ) + ) + else: + # engineers see only their own + query = query.where(ScriptTemplate.created_by == current_user.id) + + 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: + if not can_create_content(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Engineer access required to create templates", + ) + + 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: + result = await db.execute( + select(ScriptTemplate).where( + ScriptTemplate.id == template_id, + ScriptTemplate.is_active == True, # noqa: E712 + ) + ) + template = result.scalar_one_or_none() + if not template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Template not found", + ) + + if not can_manage_script_template(current_user, template.created_by, template.team_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to edit this template", + ) + + 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: + result = await db.execute( + select(ScriptTemplate).where( + ScriptTemplate.id == template_id, + ScriptTemplate.is_active == True, # noqa: E712 + ) + ) + template = result.scalar_one_or_none() + if not template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Template not found", + ) + + if not can_manage_script_template(current_user, template.created_by, template.team_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to delete this template", + ) + + template.is_active = False + await db.commit() + + +@router.patch("/templates/{template_id}/share", response_model=ScriptTemplateDetail) +async def share_template( + template_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], + shared: bool = Query(..., description="true to share with team, false to make personal"), +) -> ScriptTemplateDetail: + """Toggle team sharing for a template. Owner/admin/super_admin only.""" + if not (current_user.is_super_admin or current_user.account_role == "owner"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only account owners and admins can share templates", + ) + + result = await db.execute( + select(ScriptTemplate).where( + ScriptTemplate.id == template_id, + ScriptTemplate.is_active == True, # noqa: E712 + ) + ) + template = result.scalar_one_or_none() + if not template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Template not found", + ) + + if shared: + template.team_id = current_user.team_id + else: + template.team_id = None + + await db.commit() + await db.refresh(template) + return ScriptTemplateDetail.model_validate(template) + + +# ── 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/app/core/permissions.py b/backend/app/core/permissions.py index ba34dd62..639c4af6 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -169,3 +169,19 @@ def can_create_step_category(user: User, account_id: Optional[UUID]) -> bool: if user.account_role == "owner" and account_id == user.account_id and user.account_id is not None: return True return False + + +def can_manage_script_template(user: User, template_created_by: Optional[UUID], template_account_id: Optional[UUID] = None) -> bool: + """Can the user edit/delete this script template? + + - Super admins can manage any template + - Account owners can manage any template in their account + - Engineers can manage templates they created + """ + if user.is_super_admin: + return True + if user.account_role == "owner" and template_account_id == user.account_id and user.account_id is not None: + return True + if template_created_by == user.id: + return True + return False 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") 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..1dbdc561 --- /dev/null +++ b/backend/app/schemas/script_template.py @@ -0,0 +1,138 @@ +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 + created_by: 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 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/conftest.py b/backend/tests/conftest.py index a1d0221d..c3fe7a05 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -20,7 +20,13 @@ from app.core.config import settings settings.REQUIRE_INVITE_CODE = False # Test database URL (separate from production) -TEST_DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/patherly_test" +# Use DATABASE_TEST_URL env var if set (e.g. inside Docker where host is 'db'), +# otherwise fall back to localhost for local development. +import os +TEST_DATABASE_URL = os.environ.get( + "DATABASE_TEST_URL", + "postgresql+asyncpg://postgres:postgres@localhost:5432/patherly_test", +) @pytest.fixture(scope="session") 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]" diff --git a/backend/tests/test_script_templates.py b/backend/tests/test_script_templates.py new file mode 100644 index 00000000..868bf10e --- /dev/null +++ b/backend/tests/test_script_templates.py @@ -0,0 +1,238 @@ +"""Integration tests for Script Template Editor permissions and share endpoint.""" +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user import User +from app.models.script_template import ScriptCategory, ScriptTemplate + + +# ── Helpers ────────────────────────────────────────────────────────────── + +async def _create_category(db: AsyncSession) -> ScriptCategory: + """Seed a script category for tests.""" + cat = ScriptCategory(name="Active Directory", slug="active-directory", sort_order=1) + db.add(cat) + await db.commit() + await db.refresh(cat) + return cat + + +async def _make_owner(db: AsyncSession, user_id: str) -> None: + """Promote a user to account owner.""" + from uuid import UUID as PyUUID + result = await db.execute(select(User).where(User.id == PyUUID(user_id))) + user = result.scalar_one() + user.account_role = "owner" + await db.commit() + + +async def _register_and_login(client: AsyncClient, email: str, password: str, name: str) -> tuple[dict, str]: + """Register a user, login, return (user_data, access_token).""" + resp = await client.post("/api/v1/auth/register", json={"email": email, "password": password, "name": name}) + assert resp.status_code in (200, 201) + user_data = resp.json() + login_resp = await client.post("/api/v1/auth/login/json", json={"email": email, "password": password}) + assert login_resp.status_code == 200 + token = login_resp.json()["access_token"] + return user_data, token + + +TEMPLATE_PAYLOAD = { + "name": "Test Template", + "script_body": "Write-Host '{{ message }}'", + "parameters_schema": { + "parameters": [ + {"key": "message", "label": "Message", "type": "text", "required": True, "order": 1} + ] + }, + "complexity": "beginner", +} + + +# ── Tests ──────────────────────────────────────────────────────────────── + +class TestScriptTemplatePermissions: + """Test that engineers can create/edit their own templates, but not others'.""" + + @pytest.mark.asyncio + async def test_engineer_can_create_template(self, client, auth_headers, test_db): + cat = await _create_category(test_db) + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "Test Template" + assert data["created_by"] is not None + + @pytest.mark.asyncio + async def test_engineer_can_edit_own_template(self, client, auth_headers, test_db): + cat = await _create_category(test_db) + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers) + template_id = create_resp.json()["id"] + + update_resp = await client.put( + f"/api/v1/scripts/templates/{template_id}", + json={"name": "Updated Template"}, + headers=auth_headers, + ) + assert update_resp.status_code == 200 + assert update_resp.json()["name"] == "Updated Template" + + @pytest.mark.asyncio + async def test_engineer_cannot_edit_others_template(self, client, test_db): + cat = await _create_category(test_db) + + # Engineer A creates a template + _, token_a = await _register_and_login(client, "engineer_a@example.com", "TestPass123!", "Engineer A") + headers_a = {"Authorization": f"Bearer {token_a}"} + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers_a) + template_id = create_resp.json()["id"] + + # Engineer B tries to edit it + _, token_b = await _register_and_login(client, "engineer_b@example.com", "TestPass123!", "Engineer B") + headers_b = {"Authorization": f"Bearer {token_b}"} + update_resp = await client.put( + f"/api/v1/scripts/templates/{template_id}", + json={"name": "Hijacked!"}, + headers=headers_b, + ) + assert update_resp.status_code == 403 + + @pytest.mark.asyncio + async def test_engineer_can_delete_own_template(self, client, auth_headers, test_db): + cat = await _create_category(test_db) + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers) + template_id = create_resp.json()["id"] + + delete_resp = await client.delete(f"/api/v1/scripts/templates/{template_id}", headers=auth_headers) + assert delete_resp.status_code == 204 + + @pytest.mark.asyncio + async def test_viewer_cannot_create_template(self, client, test_db): + _, token = await _register_and_login(client, "viewer@example.com", "TestPass123!", "Viewer") + # Downgrade to viewer + result = await test_db.execute(select(User).where(User.email == "viewer@example.com")) + user = result.scalar_one() + user.role = "viewer" + user.account_role = "viewer" + await test_db.commit() + + # Re-login to get new token with updated role + login_resp = await client.post("/api/v1/auth/login/json", json={"email": "viewer@example.com", "password": "TestPass123!"}) + token = login_resp.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + cat = await _create_category(test_db) + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers) + assert resp.status_code == 403 + + @pytest.mark.asyncio + async def test_admin_can_edit_others_template(self, client, test_db, admin_auth_headers): + cat = await _create_category(test_db) + # Create template as a regular engineer + _, token = await _register_and_login(client, "eng@example.com", "TestPass123!", "Eng") + headers = {"Authorization": f"Bearer {token}"} + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers) + template_id = create_resp.json()["id"] + + # Admin edits it + update_resp = await client.put( + f"/api/v1/scripts/templates/{template_id}", + json={"name": "Admin Updated"}, + headers=admin_auth_headers, + ) + assert update_resp.status_code == 200 + assert update_resp.json()["name"] == "Admin Updated" + + @pytest.mark.asyncio + async def test_managed_filter_returns_own_templates(self, client, test_db): + cat = await _create_category(test_db) + + # Engineer A creates a template + _, token_a = await _register_and_login(client, "eng_a2@example.com", "TestPass123!", "Eng A") + headers_a = {"Authorization": f"Bearer {token_a}"} + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + await client.post("/api/v1/scripts/templates", json=payload, headers=headers_a) + + # Engineer B should not see A's template in managed view + _, token_b = await _register_and_login(client, "eng_b2@example.com", "TestPass123!", "Eng B") + headers_b = {"Authorization": f"Bearer {token_b}"} + + resp = await client.get("/api/v1/scripts/templates?managed=true", headers=headers_b) + assert resp.status_code == 200 + assert len(resp.json()) == 0 + + +class TestScriptTemplateShare: + """Test the share/unshare endpoint.""" + + @pytest.mark.asyncio + async def test_owner_can_share_template(self, client, test_db): + cat = await _create_category(test_db) + + # Registration auto-sets account_role="owner", so this user is already an owner + user_data, token = await _register_and_login(client, "eng_share@example.com", "TestPass123!", "Eng") + headers = {"Authorization": f"Bearer {token}"} + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers) + template_id = create_resp.json()["id"] + + # Share — owner should be allowed + share_resp = await client.patch( + f"/api/v1/scripts/templates/{template_id}/share?shared=true", + headers=headers, + ) + assert share_resp.status_code == 200 + + @pytest.mark.asyncio + async def test_engineer_cannot_share_template(self, client, test_db): + cat = await _create_category(test_db) + + # Create a user and downgrade to engineer (registration sets owner by default) + user_data, token = await _register_and_login(client, "eng_noshare@example.com", "TestPass123!", "Eng NoShare") + result = await test_db.execute(select(User).where(User.email == "eng_noshare@example.com")) + user = result.scalar_one() + user.account_role = "engineer" + await test_db.commit() + + # Re-login to get fresh token with updated role + login_resp = await client.post("/api/v1/auth/login/json", json={"email": "eng_noshare@example.com", "password": "TestPass123!"}) + eng_token = login_resp.json()["access_token"] + eng_headers = {"Authorization": f"Bearer {eng_token}"} + + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=eng_headers) + template_id = create_resp.json()["id"] + + share_resp = await client.patch( + f"/api/v1/scripts/templates/{template_id}/share?shared=true", + headers=eng_headers, + ) + assert share_resp.status_code == 403 + + @pytest.mark.asyncio + async def test_owner_can_unshare_template(self, client, test_db): + cat = await _create_category(test_db) + + # Registration auto-sets account_role="owner" + user_data, token = await _register_and_login(client, "eng_unshare@example.com", "TestPass123!", "Eng") + headers = {"Authorization": f"Bearer {token}"} + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers) + template_id = create_resp.json()["id"] + + # Share then unshare + await client.patch(f"/api/v1/scripts/templates/{template_id}/share?shared=true", headers=headers) + unshare_resp = await client.patch( + f"/api/v1/scripts/templates/{template_id}/share?shared=false", + headers=headers, + ) + assert unshare_resp.status_code == 200 + assert unshare_resp.json()["team_id"] is None 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 diff --git a/docs/plans/2026-03-13-script-template-editor-design.md b/docs/plans/2026-03-13-script-template-editor-design.md new file mode 100644 index 00000000..0a23c99f --- /dev/null +++ b/docs/plans/2026-03-13-script-template-editor-design.md @@ -0,0 +1,157 @@ +# Script Template Editor — Design Document + +> **Date:** 2026-03-13 +> **Status:** Approved +> **Depends on:** Script Generator Phase 1 (backend) + Phase 2 (Script Library frontend) + +--- + +## Goal + +Build a Script Template Editor page where engineers can create and manage their own PowerShell script templates, and owners/admins can promote personal templates to team-wide visibility via a "Share with team" toggle. + +--- + +## Page Structure + +**Route:** `/scripts/manage` + +**Two modes:** + +- **List mode** — all templates the user can see/edit. Filterable by category, searchable by name. Each row: name, category, complexity badge, usage count, scope badge ("Personal" / "Team"), action buttons (Edit, Delete). +- **Editor mode** — full-page form for creating or editing a template. No modal — the template has too many fields. "Back to templates" link with unsaved-changes warning if dirty. + +**Navigation entry points:** +- "Manage Templates" link on the Script Library page header (visible to engineers+) +- Direct URL `/scripts/manage` + +--- + +## Permissions + +### Who sees what in the list + +| Role | Visible templates | +|------|------------------| +| Engineer | Own templates (`created_by = user.id`) + team templates (`team_id = user.team_id`) | +| Owner / Admin | All templates in their account scope | +| Super admin | All templates across all accounts | + +### Who can do what + +| Action | Engineer | Owner/Admin | Super Admin | +|--------|----------|-------------|-------------| +| Create template | Yes (personal scope) | Yes (personal or team) | Yes (any scope) | +| Edit own template | Yes | Yes | Yes | +| Edit others' templates | No | Yes (within account) | Yes (all) | +| Delete own template | Yes (soft delete) | Yes | Yes | +| Delete others' templates | No | Yes (within account) | Yes (all) | +| "Share with team" toggle | No | Yes | Yes | + +--- + +## Backend Changes + +### Permission refactor + +Replace `_require_team_admin()` with `_check_template_permission(user, template?)`: +- **Create:** engineers+ can create (personal scope) +- **Edit/Delete:** engineers can modify own templates (`created_by == user.id`); owners/admins can modify any template in their account; super admins can modify any +- Existing `POST /scripts/templates` sets `created_by = user.id` and `team_id = null` for engineers (personal scope) + +### New endpoint + +`PATCH /scripts/templates/{id}/share` — owner/admin/super_admin only +- Body: `{ "shared": boolean }` +- `shared=true` → sets `team_id` to the user's `team_id` +- `shared=false` → clears `team_id` to `null` (reverts to personal scope for original author) +- Returns updated `ScriptTemplateDetail` + +### Query filter + +Update `GET /scripts/templates` to support `managed=true` query param: +- Returns templates the user can edit (own templates + team templates for owners/admins) +- Used by the manage page list view + +--- + +## Template Editor Form + +Single scrollable page with sections separated by dividers. Fixed bottom action bar. + +### Section 1: Metadata + +- **Name** — text input, required +- **Description** — textarea +- **Use Case** — textarea ("when would you use this?") +- **Category** — select dropdown from existing categories +- **Complexity** — select: beginner / intermediate / advanced +- **Tags** — multi-text input (comma-separated or chip-style) +- **Estimated Runtime** — text input (e.g., "30 seconds") +- **Requires Elevation** — checkbox +- **Required Modules** — multi-text input +- **Share with team** — toggle switch, visible only to owners/admins/super_admins. Help text: "When enabled, all team members can browse and use this template." + +### Section 2: Script Body + +- Large textarea with `PowerShellHighlighter` for syntax coloring +- Monospace font (`font-label` / JetBrains Mono) +- `{{parameter_key}}` placeholders highlighted in amber so authors can see where parameters slot in +- Simple textarea with highlighting overlay (no Monaco/CodeMirror dependency) + +### Section 3: Parameters Schema + +Two modes with a toggle at the top of the section: + +**Visual mode (default):** +- List of parameter cards, each expandable/collapsible +- "Add Parameter" button at bottom +- Each card: key, label, type (select from 7 types), required toggle, placeholder, group, order, help_text, default value, sensitive toggle +- For `select` type: options sub-list (value + label pairs) +- For types with validation: min/max/pattern fields +- Drag-to-reorder or up/down arrows for parameter ordering + +**JSON mode:** +- Raw JSON editor showing the `parameters_schema` object +- Edits sync back to visual mode on switch +- Parse errors shown inline + +### Section 4: Fixed Action Bar + +- **Save** — primary button, creates or updates template +- **Cancel** — back to list with dirty-state warning +- **Delete** — danger button (right-aligned), only in edit mode, confirmation modal, soft delete + +--- + +## Team Sharing Behavior + +- **Default for engineer-created templates:** `team_id = null` (personal, only visible to creator) +- **Shared:** `team_id` set to account's team — template appears in Script Library for all team members +- **Unsharing:** reverts to personal scope for original author; author retains edit access via `created_by` + +--- + +## Frontend Components (Expected) + +| Component | Responsibility | +|-----------|---------------| +| `ScriptManagePage.tsx` | Page shell, list/editor mode toggle | +| `ScriptTemplateListView.tsx` | Template list with filters, search, action buttons | +| `ScriptTemplateEditor.tsx` | Full editor form — metadata, script body, parameters | +| `ParameterSchemaBuilder.tsx` | Visual parameter builder with add/remove/reorder | +| `ParameterCard.tsx` | Single parameter editor (expandable card) | +| `ParameterJsonEditor.tsx` | Raw JSON mode for parameters schema | +| `ScriptBodyEditor.tsx` | Textarea with PowerShell highlighting overlay | +| `ShareToggle.tsx` | Team sharing toggle (owner/admin only) | + +--- + +## Design System Compliance + +- Dark glassmorphism theme, `.glass-card-static` containers +- Primary actions: `bg-gradient-brand` +- Section labels: `font-label text-[0.625rem] uppercase tracking-[0.1em]` +- Form inputs: `border-border bg-card text-foreground` with cyan focus ring +- Complexity badges: emerald (beginner), amber (intermediate), rose (advanced) +- Scope badges: "Personal" (muted border) / "Team" (cyan/primary tint) diff --git a/docs/plans/2026-03-13-script-template-editor-impl.md b/docs/plans/2026-03-13-script-template-editor-impl.md new file mode 100644 index 00000000..08ff8a77 --- /dev/null +++ b/docs/plans/2026-03-13-script-template-editor-impl.md @@ -0,0 +1,2253 @@ +# Script Template Editor — Implementation Plan + +> **For Claude:** REQUIRED: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Script Template Editor page (`/scripts/manage`) where engineers create personal templates, owners/admins can edit any and toggle "Share with team", and super admins have full access. + +**Architecture:** Refactor backend permission checks on existing CRUD endpoints to allow engineers to manage their own templates. Add `PATCH /share` endpoint. Build a new frontend page with list/editor modes, a visual parameter schema builder with JSON toggle, and a PowerShell script body editor. + +**Tech Stack:** Python FastAPI, SQLAlchemy 2.0, Pydantic v2, React 19, TypeScript, Zustand, Axios (`apiClient`), Tailwind CSS v3, Lucide React. + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `backend/app/api/endpoints/scripts.py` | Modify | Refactor permissions, add `/share` endpoint, add `managed` filter | +| `backend/app/schemas/script_template.py` | Modify | Add `created_by` to response schemas | +| `backend/app/core/permissions.py` | Modify | Add `can_manage_script_template()` | +| `backend/tests/test_script_templates.py` | Create | Integration tests for permission changes + share endpoint | +| `frontend/src/types/scripts.ts` | Modify | Add `created_by` to list/detail types, add create/update request types | +| `frontend/src/api/scripts.ts` | Modify | Add `createTemplate`, `updateTemplate`, `deleteTemplate`, `shareTemplate`, `getManagedTemplates` | +| `frontend/src/hooks/usePermissions.ts` | Modify | Add `canManageScriptTemplate()` check | +| `frontend/src/pages/ScriptManagePage.tsx` | Create | Page shell — list/editor mode toggle | +| `frontend/src/components/script-editor/ScriptTemplateListView.tsx` | Create | Template list with filters, search, scope badges, actions | +| `frontend/src/components/script-editor/ScriptTemplateEditor.tsx` | Create | Full editor form — metadata, script body, parameters, actions | +| `frontend/src/components/script-editor/ScriptBodyEditor.tsx` | Create | Textarea with PowerShell highlighting overlay | +| `frontend/src/components/script-editor/ParameterSchemaBuilder.tsx` | Create | Visual parameter builder + JSON toggle | +| `frontend/src/components/script-editor/ParameterCard.tsx` | Create | Single parameter editor (expandable card) | +| `frontend/src/router.tsx` | Modify | Add `/scripts/manage` route | +| `frontend/src/pages/ScriptLibraryPage.tsx` | Modify | Add "Manage Templates" link for engineers+ | + +--- + +## Chunk 1: Backend — Permission Refactor + Share Endpoint + +### Task 1: Add `can_manage_script_template` to permissions.py + +**Files:** +- Modify: `backend/app/core/permissions.py` + +- [ ] **Step 1: Add the permission function** + +Add at the end of `backend/app/core/permissions.py`: + +```python +def can_manage_script_template(user: User, template_created_by: Optional[UUID], template_account_id: Optional[UUID] = None) -> bool: + """Can the user edit/delete this script template? + + - Super admins can manage any template + - Account owners can manage any template in their account + - Engineers can manage templates they created + """ + if user.is_super_admin: + return True + if user.account_role == "owner" and template_account_id == user.account_id and user.account_id is not None: + return True + if template_created_by == user.id: + return True + return False +``` + +Add the import at the top if not present: +```python +from uuid import UUID +``` + +- [ ] **Step 2: Verify no import errors** + +```bash +docker exec resolutionflow_backend python -c "from app.core.permissions import can_manage_script_template; print('OK')" +``` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/core/permissions.py +git commit -m "feat: add can_manage_script_template permission check + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +### Task 2: Add `created_by` to response schemas + +**Files:** +- Modify: `backend/app/schemas/script_template.py` + +- [ ] **Step 1: Add `created_by` field to `ScriptTemplateListItem`** + +In `backend/app/schemas/script_template.py`, add `created_by` to `ScriptTemplateListItem` (line ~82, after `team_id`): + +```python +class ScriptTemplateListItem(BaseModel): + id: UUID + category_id: UUID + team_id: Optional[UUID] = None + created_by: Optional[UUID] = None # ← ADD THIS LINE + name: str + slug: str + # ... rest stays the same +``` + +- [ ] **Step 2: Verify import works** + +```bash +docker exec resolutionflow_backend python -c "from app.schemas.script_template import ScriptTemplateListItem; print(ScriptTemplateListItem.model_fields.keys())" +``` +Expected: output includes `created_by` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/schemas/script_template.py +git commit -m "feat: expose created_by in script template response schemas + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +### Task 3: Refactor script endpoint permissions + +**Files:** +- Modify: `backend/app/api/endpoints/scripts.py` + +- [ ] **Step 1: Replace `_require_team_admin` with new permission logic** + +In `backend/app/api/endpoints/scripts.py`: + +1. Remove the `_require_team_admin` function (lines 30-36). + +2. Add these imports at the top: +```python +from app.core.permissions import can_manage_script_template, can_create_content +``` + +3. Replace the `create_template` endpoint permission check. Change: +```python +_require_team_admin(current_user) +``` +to: +```python +if not can_create_content(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Engineer access required to create templates", + ) +``` + +4. Replace the `update_template` endpoint. Change the permission check AND the query. The current query filters by `team_id == current_user.team_id` which is too restrictive. Replace the full endpoint: + +```python +@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: + result = await db.execute( + select(ScriptTemplate).where( + ScriptTemplate.id == template_id, + ScriptTemplate.is_active == True, # noqa: E712 + ) + ) + template = result.scalar_one_or_none() + if not template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Template not found", + ) + + if not can_manage_script_template(current_user, template.created_by, template.team_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to edit this template", + ) + + 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) +``` + +5. Replace the `delete_template` endpoint similarly: + +```python +@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: + result = await db.execute( + select(ScriptTemplate).where( + ScriptTemplate.id == template_id, + ScriptTemplate.is_active == True, # noqa: E712 + ) + ) + template = result.scalar_one_or_none() + if not template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Template not found", + ) + + if not can_manage_script_template(current_user, template.created_by, template.team_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to delete this template", + ) + + template.is_active = False + await db.commit() +``` + +- [ ] **Step 2: Add `managed` query param to `list_templates`** + +In the `list_templates` endpoint, add a new query parameter and filter. Add after the existing `tags` param: + +```python +managed: Optional[bool] = Query(None, description="If true, return only templates this user can edit"), +``` + +Add this filter block after the existing `search` filter (before executing the query): + +```python +if managed: + if current_user.is_super_admin: + pass # super admin can edit all + elif current_user.account_role == "owner": + # owners see account-scoped templates + query = query.where( + or_( + ScriptTemplate.created_by == current_user.id, + ScriptTemplate.team_id != None, # noqa: E711 + ) + ) + else: + # engineers see only their own + query = query.where(ScriptTemplate.created_by == current_user.id) +``` + +- [ ] **Step 3: Add the share endpoint** + +Add at the end of `backend/app/api/endpoints/scripts.py` (before the generations section): + +```python +@router.patch("/templates/{template_id}/share", response_model=ScriptTemplateDetail) +async def share_template( + template_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], + shared: bool = Query(..., description="true to share with team, false to make personal"), +) -> ScriptTemplateDetail: + """Toggle team sharing for a template. Owner/admin/super_admin only.""" + if not (current_user.is_super_admin or current_user.account_role == "owner"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only account owners and admins can share templates", + ) + + result = await db.execute( + select(ScriptTemplate).where( + ScriptTemplate.id == template_id, + ScriptTemplate.is_active == True, # noqa: E712 + ) + ) + template = result.scalar_one_or_none() + if not template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Template not found", + ) + + if shared: + template.team_id = current_user.team_id + else: + template.team_id = None + + await db.commit() + await db.refresh(template) + return ScriptTemplateDetail.model_validate(template) +``` + +- [ ] **Step 4: Verify the backend starts** + +```bash +docker compose -f docker-compose.dev.yml restart backend && sleep 3 && curl -s http://localhost:8000/api/docs | head -5 +``` +Expected: Backend starts without import errors. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/endpoints/scripts.py +git commit -m "feat: refactor script template permissions — engineers manage own, add /share endpoint + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +### Task 4: Backend integration tests + +**Files:** +- Create: `backend/tests/test_script_templates.py` + +- [ ] **Step 1: Write the test file** + +```python +"""Integration tests for Script Template Editor permissions and share endpoint.""" +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user import User +from app.models.script_template import ScriptCategory, ScriptTemplate + + +# ── Helpers ────────────────────────────────────────────────────────────── + +async def _create_category(db: AsyncSession) -> ScriptCategory: + """Seed a script category for tests.""" + cat = ScriptCategory(name="Active Directory", slug="active-directory", sort_order=1) + db.add(cat) + await db.commit() + await db.refresh(cat) + return cat + + +async def _make_owner(db: AsyncSession, user_id: str) -> None: + """Promote a user to account owner.""" + from uuid import UUID as PyUUID + result = await db.execute(select(User).where(User.id == PyUUID(user_id))) + user = result.scalar_one() + user.account_role = "owner" + await db.commit() + + +async def _register_and_login(client: AsyncClient, email: str, password: str, name: str) -> tuple[dict, str]: + """Register a user, login, return (user_data, access_token).""" + resp = await client.post("/api/v1/auth/register", json={"email": email, "password": password, "name": name}) + assert resp.status_code in (200, 201) + user_data = resp.json() + login_resp = await client.post("/api/v1/auth/login/json", json={"email": email, "password": password}) + assert login_resp.status_code == 200 + token = login_resp.json()["access_token"] + return user_data, token + + +TEMPLATE_PAYLOAD = { + "name": "Test Template", + "script_body": "Write-Host '{{ message }}'", + "parameters_schema": { + "parameters": [ + {"key": "message", "label": "Message", "type": "text", "required": True, "order": 1} + ] + }, + "complexity": "beginner", +} + + +# ── Tests ──────────────────────────────────────────────────────────────── + +class TestScriptTemplatePermissions: + """Test that engineers can create/edit their own templates, but not others'.""" + + @pytest.mark.asyncio + async def test_engineer_can_create_template(self, client, auth_headers, test_db): + cat = await _create_category(test_db) + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "Test Template" + assert data["created_by"] is not None + + @pytest.mark.asyncio + async def test_engineer_can_edit_own_template(self, client, auth_headers, test_db): + cat = await _create_category(test_db) + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers) + template_id = create_resp.json()["id"] + + update_resp = await client.put( + f"/api/v1/scripts/templates/{template_id}", + json={"name": "Updated Template"}, + headers=auth_headers, + ) + assert update_resp.status_code == 200 + assert update_resp.json()["name"] == "Updated Template" + + @pytest.mark.asyncio + async def test_engineer_cannot_edit_others_template(self, client, test_db): + cat = await _create_category(test_db) + + # Engineer A creates a template + _, token_a = await _register_and_login(client, "engineer_a@example.com", "TestPass123!", "Engineer A") + headers_a = {"Authorization": f"Bearer {token_a}"} + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers_a) + template_id = create_resp.json()["id"] + + # Engineer B tries to edit it + _, token_b = await _register_and_login(client, "engineer_b@example.com", "TestPass123!", "Engineer B") + headers_b = {"Authorization": f"Bearer {token_b}"} + update_resp = await client.put( + f"/api/v1/scripts/templates/{template_id}", + json={"name": "Hijacked!"}, + headers=headers_b, + ) + assert update_resp.status_code == 403 + + @pytest.mark.asyncio + async def test_engineer_can_delete_own_template(self, client, auth_headers, test_db): + cat = await _create_category(test_db) + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers) + template_id = create_resp.json()["id"] + + delete_resp = await client.delete(f"/api/v1/scripts/templates/{template_id}", headers=auth_headers) + assert delete_resp.status_code == 204 + + @pytest.mark.asyncio + async def test_viewer_cannot_create_template(self, client, test_db): + from uuid import UUID as PyUUID + _, token = await _register_and_login(client, "viewer@example.com", "TestPass123!", "Viewer") + # Downgrade to viewer + result = await test_db.execute(select(User).where(User.email == "viewer@example.com")) + user = result.scalar_one() + user.role = "viewer" + user.account_role = "viewer" + await test_db.commit() + + # Re-login to get new token with updated role + login_resp = await client.post("/api/v1/auth/login/json", json={"email": "viewer@example.com", "password": "TestPass123!"}) + token = login_resp.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + cat = await _create_category(test_db) + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers) + assert resp.status_code == 403 + + @pytest.mark.asyncio + async def test_admin_can_edit_others_template(self, client, test_db, admin_auth_headers): + cat = await _create_category(test_db) + # Create template as a regular engineer + _, token = await _register_and_login(client, "eng@example.com", "TestPass123!", "Eng") + headers = {"Authorization": f"Bearer {token}"} + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers) + template_id = create_resp.json()["id"] + + # Admin edits it + update_resp = await client.put( + f"/api/v1/scripts/templates/{template_id}", + json={"name": "Admin Updated"}, + headers=admin_auth_headers, + ) + assert update_resp.status_code == 200 + assert update_resp.json()["name"] == "Admin Updated" + + @pytest.mark.asyncio + async def test_managed_filter_returns_own_templates(self, client, test_db): + cat = await _create_category(test_db) + + # Engineer A creates a template + _, token_a = await _register_and_login(client, "eng_a2@example.com", "TestPass123!", "Eng A") + headers_a = {"Authorization": f"Bearer {token_a}"} + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + await client.post("/api/v1/scripts/templates", json=payload, headers=headers_a) + + # Engineer B should not see A's template in managed view + _, token_b = await _register_and_login(client, "eng_b2@example.com", "TestPass123!", "Eng B") + headers_b = {"Authorization": f"Bearer {token_b}"} + + resp = await client.get("/api/v1/scripts/templates?managed=true", headers=headers_b) + assert resp.status_code == 200 + assert len(resp.json()) == 0 + + +class TestScriptTemplateShare: + """Test the share/unshare endpoint.""" + + @pytest.mark.asyncio + async def test_owner_can_share_template(self, client, test_db): + cat = await _create_category(test_db) + + # Create as engineer + user_data, token = await _register_and_login(client, "eng_share@example.com", "TestPass123!", "Eng") + headers = {"Authorization": f"Bearer {token}"} + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers) + template_id = create_resp.json()["id"] + assert create_resp.json()["team_id"] is None + + # Promote to owner and re-login + await _make_owner(test_db, user_data["id"]) + login_resp = await client.post("/api/v1/auth/login/json", json={"email": "eng_share@example.com", "password": "TestPass123!"}) + owner_token = login_resp.json()["access_token"] + owner_headers = {"Authorization": f"Bearer {owner_token}"} + + # Share + share_resp = await client.patch( + f"/api/v1/scripts/templates/{template_id}/share?shared=true", + headers=owner_headers, + ) + assert share_resp.status_code == 200 + assert share_resp.json()["team_id"] is not None + + @pytest.mark.asyncio + async def test_engineer_cannot_share_template(self, client, test_db, auth_headers): + cat = await _create_category(test_db) + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers) + template_id = create_resp.json()["id"] + + share_resp = await client.patch( + f"/api/v1/scripts/templates/{template_id}/share?shared=true", + headers=auth_headers, + ) + assert share_resp.status_code == 403 + + @pytest.mark.asyncio + async def test_owner_can_unshare_template(self, client, test_db): + cat = await _create_category(test_db) + + user_data, token = await _register_and_login(client, "eng_unshare@example.com", "TestPass123!", "Eng") + headers = {"Authorization": f"Bearer {token}"} + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers) + template_id = create_resp.json()["id"] + + # Promote to owner and re-login + await _make_owner(test_db, user_data["id"]) + login_resp = await client.post("/api/v1/auth/login/json", json={"email": "eng_unshare@example.com", "password": "TestPass123!"}) + owner_token = login_resp.json()["access_token"] + owner_headers = {"Authorization": f"Bearer {owner_token}"} + + # Share then unshare + await client.patch(f"/api/v1/scripts/templates/{template_id}/share?shared=true", headers=owner_headers) + unshare_resp = await client.patch( + f"/api/v1/scripts/templates/{template_id}/share?shared=false", + headers=owner_headers, + ) + assert unshare_resp.status_code == 200 + assert unshare_resp.json()["team_id"] is None +``` + +- [ ] **Step 2: Run the tests** + +```bash +docker exec resolutionflow_backend pytest tests/test_script_templates.py -v +``` +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add backend/tests/test_script_templates.py +git commit -m "test: add integration tests for script template permissions and share endpoint + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Chunk 2: Frontend — Types, API Client, Permissions + +### Task 5: Extend frontend types + +**Files:** +- Modify: `frontend/src/types/scripts.ts` + +- [ ] **Step 1: Add `created_by` to `ScriptTemplateListItem`** + +In `frontend/src/types/scripts.ts`, add `created_by` after the `team_id` field in `ScriptTemplateListItem`: + +```typescript +created_by: string | null +``` + +- [ ] **Step 2: Add create/update request interfaces** + +Add at the end of `frontend/src/types/scripts.ts`: + +```typescript +export interface ScriptTemplateCreateRequest { + category_id: string + name: string + description?: string | null + use_case?: string | null + script_body: string + parameters_schema: ScriptParametersSchema + tags?: string[] + complexity?: 'beginner' | 'intermediate' | 'advanced' + estimated_runtime?: string | null + requires_elevation?: boolean + requires_modules?: string[] +} + +export interface ScriptTemplateUpdateRequest { + name?: string + description?: string | null + use_case?: string | null + script_body?: string + parameters_schema?: ScriptParametersSchema + tags?: string[] + complexity?: 'beginner' | 'intermediate' | 'advanced' + estimated_runtime?: string | null + requires_elevation?: boolean + requires_modules?: string[] +} +``` + +- [ ] **Step 3: Verify build** + +```bash +docker exec resolutionflow_frontend npm run build 2>&1 | tail -10 +``` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/types/scripts.ts +git commit -m "feat: add created_by and create/update request types for script templates + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +### Task 6: Extend API client + +**Files:** +- Modify: `frontend/src/api/scripts.ts` + +- [ ] **Step 1: Add CRUD + share methods to `scriptsApi`** + +Add the following imports to the top of `frontend/src/api/scripts.ts`: + +```typescript +import type { + ScriptCategoryResponse, + ScriptTemplateListItem, + ScriptTemplateDetail, + ScriptGenerateRequest, + ScriptGenerateResponse, + ScriptGenerationRecord, + ScriptTemplateCreateRequest, + ScriptTemplateUpdateRequest, +} from '@/types' +``` + +Add these methods inside the `scriptsApi` object (before the closing `}`): + +```typescript + async getManagedTemplates(params?: { + category_slug?: string + search?: string + }): Promise { + const response = await apiClient.get('/scripts/templates', { + params: { ...params, managed: true }, + }) + return response.data + }, + + async createTemplate(data: ScriptTemplateCreateRequest): Promise { + const response = await apiClient.post('/scripts/templates', data) + return response.data + }, + + async updateTemplate(id: string, data: ScriptTemplateUpdateRequest): Promise { + const response = await apiClient.put(`/scripts/templates/${id}`, data) + return response.data + }, + + async deleteTemplate(id: string): Promise { + await apiClient.delete(`/scripts/templates/${id}`) + }, + + async shareTemplate(id: string, shared: boolean): Promise { + const response = await apiClient.patch( + `/scripts/templates/${id}/share`, + null, + { params: { shared } }, + ) + return response.data + }, +``` + +- [ ] **Step 2: Verify build** + +```bash +docker exec resolutionflow_frontend npm run build 2>&1 | tail -10 +``` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/api/scripts.ts +git commit -m "feat: add CRUD and share methods to scriptsApi client + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +### Task 7: Add permission check to usePermissions + +**Files:** +- Modify: `frontend/src/hooks/usePermissions.ts` + +- [ ] **Step 1: Add `canManageScriptTemplate` to the returned object** + +In `frontend/src/hooks/usePermissions.ts`, add inside the returned object (after `canManageGlobalCategories`): + +```typescript + canManageScriptTemplate: (template: { created_by: string | null; team_id?: string | null }) => { + if (!user) return false + if (user.is_super_admin) return true + if (user.account_role === 'owner') return true + return template.created_by === user.id + }, + + canShareScriptTemplate: effectiveRole === 'super_admin' || effectiveRole === 'owner', + + canCreateScriptTemplate: hasMinimumRole(user, 'engineer'), +``` + +- [ ] **Step 2: Verify build** + +```bash +docker exec resolutionflow_frontend npm run build 2>&1 | tail -10 +``` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/hooks/usePermissions.ts +git commit -m "feat: add script template permission checks to usePermissions hook + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Chunk 3: Frontend — Script Template List View + +### Task 8: ScriptTemplateListView component + +**Files:** +- Create: `frontend/src/components/script-editor/ScriptTemplateListView.tsx` + +- [ ] **Step 1: Create the component** + +```tsx +import { useState, useEffect } from 'react' +import { Plus, Search, Pencil, Trash2, Users, User as UserIcon, Loader2, FileCode } from 'lucide-react' +import { cn } from '@/lib/utils' +import { usePermissions } from '@/hooks/usePermissions' +import { scriptsApi } from '@/api' +import type { ScriptTemplateListItem, ScriptCategoryResponse } from '@/types' + +const COMPLEXITY_CLASSES = { + beginner: 'text-emerald-400 bg-emerald-400/10', + intermediate: 'text-amber-400 bg-amber-400/10', + advanced: 'text-rose-500 bg-rose-500/10', +} as const + +interface Props { + onEdit: (id: string) => void + onCreate: () => void +} + +export function ScriptTemplateListView({ onEdit, onCreate }: Props) { + const [templates, setTemplates] = useState([]) + const [categories, setCategories] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [searchQuery, setSearchQuery] = useState('') + const [deleteConfirm, setDeleteConfirm] = useState(null) + + const { canManageScriptTemplate, canCreateScriptTemplate } = usePermissions() + + const loadData = async () => { + setIsLoading(true) + try { + const [tpls, cats] = await Promise.all([ + scriptsApi.getManagedTemplates(searchQuery ? { search: searchQuery } : undefined), + scriptsApi.getCategories(), + ]) + setTemplates(tpls) + setCategories(cats) + } catch { + // silently fail + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + loadData() + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const timer = setTimeout(() => { + loadData() + }, 300) + return () => clearTimeout(timer) + }, [searchQuery]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleDelete = async (id: string) => { + try { + await scriptsApi.deleteTemplate(id) + setTemplates(prev => prev.filter(t => t.id !== id)) + setDeleteConfirm(null) + } catch { + // silently fail + } + } + + const getCategoryName = (categoryId: string) => + categories.find(c => c.id === categoryId)?.name ?? 'Unknown' + + return ( +
+ {/* Header row */} +
+
+

Manage Templates

+

+ Create and edit PowerShell script templates. +

+
+ {canCreateScriptTemplate && ( + + )} +
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search templates…" + className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-border bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-[rgba(6,182,212,0.3)] focus:ring-1 focus:ring-[rgba(6,182,212,0.2)]" + /> +
+ + {/* Template list */} + {isLoading ? ( +
+ +
+ ) : templates.length === 0 ? ( +
+ +

+ {searchQuery ? 'No templates match your search' : 'No templates yet. Create your first one!'} +

+
+ ) : ( +
+ + + + + + + + + + + + + {templates.map(t => ( + + + + + + + + + ))} + +
NameCategoryComplexityScopeUsesActions
+ {t.name} + {t.description && ( +

{t.description}

+ )} +
{getCategoryName(t.category_id)} + + {t.complexity} + + + + {t.team_id ? <> Team : <> Personal} + + {t.usage_count} +
+ {canManageScriptTemplate(t) && ( + <> + + {deleteConfirm === t.id ? ( +
+ + +
+ ) : ( + + )} + + )} +
+
+
+ )} +
+ ) +} +``` + +- [ ] **Step 2: Verify build** + +```bash +docker exec resolutionflow_frontend npm run build 2>&1 | tail -10 +``` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/components/script-editor/ScriptTemplateListView.tsx +git commit -m "feat: add ScriptTemplateListView component + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Chunk 4: Frontend — Script Body Editor + +### Task 9: ScriptBodyEditor component + +**Files:** +- Create: `frontend/src/components/script-editor/ScriptBodyEditor.tsx` + +- [ ] **Step 1: Create the component** + +```tsx +import { useRef, useCallback } from 'react' +import { PowerShellHighlighter } from '@/components/scripts/PowerShellHighlighter' + +interface Props { + value: string + onChange: (value: string) => void + disabled?: boolean +} + +export function ScriptBodyEditor({ value, onChange, disabled }: Props) { + const textareaRef = useRef(null) + + const handleTab = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Tab') { + e.preventDefault() + const ta = e.currentTarget + const start = ta.selectionStart + const end = ta.selectionEnd + const newValue = value.substring(0, start) + ' ' + value.substring(end) + onChange(newValue) + // Restore cursor position after React re-render + requestAnimationFrame(() => { + ta.selectionStart = ta.selectionEnd = start + 4 + }) + } + }, [value, onChange]) + + return ( +
+ {/* Highlighted overlay (read-only visual layer) */} +
+ +
+ + {/* Editable textarea (transparent text, visible caret) */} +