diff --git a/CHANGELOG.md b/CHANGELOG.md index 824b2bb..85425fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.5.9 + +- Select existing workspace categories or add a new category in form settings. +- Export portable form JSON from the list or editor. Import validated files as new, unsaved drafts using the destination workspace’s access defaults. Existing forms, submissions, and connection settings are preserved. + +## 0.5.8 + +- Replace the checkbox default text input with a Checked / Unchecked selector so both boolean defaults can be set reliably. + ## 0.5.7 - Make catalog Open form controls compact buttons with keyboard focus styling. In dark mode, use a dark iridescent fill that lights up on hover or keyboard focus, with a shine animation on hover. diff --git a/README.md b/README.md index 8cad513..b1360d0 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ ActionStack is an AI-driven project attempting to build something useful for the - Automation catalog with search, categories, favorites, and pagination. - Form builder with conditional fields, validation, lookup inputs, multiple-value inputs, and collapsible sections. - Drafts, publishing, version history, cloning, and recoverable form deletion. +- Reusable workspace categories and JSON form export/import. - Configurable SOAR labels, tags, CEF mappings, and approval requirements handled by your playbooks. - Paginated submission history with playbook/action status counts, delivery retries, and receipts with custom action names, reported block results, summaries, and data. - Optional receipt timeline and JSON receipt / CSV submission exports for audit review. @@ -33,6 +34,14 @@ Open ActionStack as a Splunk administrator. The setup wizard creates the first w See [Deployment](DEPLOYMENT.md) for configuration and permissions, and the [SOAR event contract](SOAR_EVENT_CONTRACT.md) for submitted fields. +## Moving forms between workspaces or instances + +Use **Export** in the form list, or **Export form** in the editor, to download a JSON definition. The editor export includes unsaved edits. Exports include fields, defaults, validation, conditional sections, appearance, category, lookup configuration, and SOAR mapping. They exclude source workspace permissions, form IDs, version history, submissions, and connection credentials. + +Choose **Import form**, select the JSON file (one form, up to 200 KB), and choose a destination workspace. ActionStack validates the file and opens a new, unsaved draft with a new ID and the destination workspace's default access. Review SOAR labels and lookup sources before saving or publishing; they must exist on the destination instance. Importing does not overwrite an existing form or create a SOAR event. + +In the editor's **Settings** tab, **Category** lists categories used by accessible forms in the current workspace. Choose **Add a new category…** to create another; it becomes reusable when the form is saved. + ## Development Requires Node.js 22 and Python 3.9 or 3.13. diff --git a/frontend/src/CategoryPicker.tsx b/frontend/src/CategoryPicker.tsx new file mode 100644 index 0000000..bb809e9 --- /dev/null +++ b/frontend/src/CategoryPicker.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; + +export function CategoryPicker({ + value, + categories, + onChange, +}: { + value: string; + categories: string[]; + onChange: (value: string) => void; +}) { + const [adding, setAdding] = useState(false); + const choices = Array.from( + new Set( + ["General", ...categories, value].map((c) => c.trim()).filter(Boolean), + ), + ).sort((a, b) => a.localeCompare(b)); + return ( +
+ + {adding && ( + + )} +
+ ); +} diff --git a/frontend/src/ImportForm.tsx b/frontend/src/ImportForm.tsx new file mode 100644 index 0000000..d8cd066 --- /dev/null +++ b/frontend/src/ImportForm.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { api } from "./api"; +import type { Form, Workspace } from "./types"; +import { MAX_FORM_FILE_BYTES, parseFormFile } from "./form-transfer.js"; + +export function ImportForm({ + workspaces, + workspaceId, + onImported, + onCancel, +}: { + workspaces: Workspace[]; + workspaceId: string; + onImported: (form: Form) => void; + onCancel: () => void; +}) { + const [destination, setDestination] = useState( + workspaces.some((w) => w.id === workspaceId) + ? workspaceId + : workspaces[0]?.id || "", + ); + const [file, setFile] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + return ( +
{ + e.preventDefault(); + if (!file || !destination) return; + setBusy(true); + setError(""); + try { + if (file.size > MAX_FORM_FILE_BYTES) + throw new Error( + "Choose an ActionStack form JSON file under 200 KB.", + ); + const document = parseFormFile(await file.text()); + const form = await api("/admin/forms/import", { + document, + workspace_id: destination, + }); + onImported(form); + } catch (err) { + setError((err as Error).message); + } finally { + setBusy(false); + } + }} + > +
+

+ Open an exported form as a new draft. Review its settings, then save + or publish. +

+ + +

+ The workspace’s default access applies. SOAR mapping and lookup + searches are included; check that their labels, apps, and lookups + exist here. Existing forms are kept. +

+ {error && ( +
+ {error} +
+ )} +
+
+ + +
+
+ ); +} diff --git a/frontend/src/form-transfer.js b/frontend/src/form-transfer.js new file mode 100644 index 0000000..161fcb2 --- /dev/null +++ b/frontend/src/form-transfer.js @@ -0,0 +1,40 @@ +import { cloneDefinition } from "./browser-compat.js"; + +export const MAX_FORM_FILE_BYTES = 200 * 1024; + +/** Export a portable definition, excluding environment-specific access and history. + * @param {import('./types').Form} form @param {string} appVersion */ +export function formExport(form, appVersion) { + const { title, description, category, icon, accent, intro, fields, mapping } = + form; + return { + format: "actionstack-form", + format_version: 1, + app_version: appVersion, + form: cloneDefinition({ + title, + description, + category, + icon, + accent, + intro, + fields, + mapping, + }), + }; +} + +/** @param {string} text @returns {unknown} */ +export function parseFormFile(text) { + if (new TextEncoder().encode(text).length > MAX_FORM_FILE_BYTES) + throw new Error("Choose an ActionStack form JSON file under 200 KB."); + let data; + try { + data = JSON.parse(text); + } catch { + throw new Error("This file is not valid JSON."); + } + if (!data || data.format !== "actionstack-form" || data.format_version !== 1) + throw new Error("Choose an ActionStack form export (format version 1)."); + return data; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d650f23..e29f40b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,7 @@ import React, { useEffect, useState, useRef } from "react"; import { createRoot } from "react-dom/client"; import { Download, + Upload, ArrowUpRight, ArrowRight, ArrowLeft, @@ -67,6 +68,9 @@ import { formIcon } from "./FormIcons"; import { FormAppearance, ThemePicker } from "./Appearance"; import { FormSections } from "./FormSections"; import { cloneForm } from "./clone-form.js"; +import { CategoryPicker } from "./CategoryPicker"; +import { ImportForm } from "./ImportForm"; +import { formExport, MAX_FORM_FILE_BYTES } from "./form-transfer.js"; import { ApprovalRules } from "./ApprovalRules"; import { Disclosure } from "./Disclosure"; import { RunCounts } from "./RunCounts"; @@ -1104,6 +1108,7 @@ function App() { )} {page === "builder" && ( void; }) { const [automaticId, setAutomaticId] = useState(false); + const [importing, setImporting] = useState(false); const [labels, setLabels] = useState<{ prefix: string; labels: string[] }>({ prefix: "", labels: [], @@ -1731,6 +1739,44 @@ function Builder({ "Cloned as a new draft. Review the settings, then save or publish.", ); } + function exportForm(form: Form) { + setError(""); + try { + const contents = JSON.stringify(formExport(form, appVersion), null, 2); + if (new TextEncoder().encode(contents).length > MAX_FORM_FILE_BYTES) + throw new Error("This form exceeds the 200 KB export limit."); + downloadAudit( + contents, + `actionstack-form-${form.id}.json`, + "application/json", + ); + } catch (e) { + setError((e as Error).message); + } + } + const importDialog = importing && ( + setImporting(false)}> + + w.state === "active" && + (!w.role_groups || + can(ctx, "admin") || + w.role_groups.admin.some((r) => ctx.roles.includes(r))), + )} + onCancel={() => setImporting(false)} + onImported={(form) => { + start(form); + setAutomaticId(true); + setImporting(false); + setNotice( + "Imported as a new draft. Review workspace access, SOAR mapping, and lookup sources, then save or publish.", + ); + }} + /> + + ); function update(patch: Partial
) { if ( editor && @@ -1832,10 +1878,16 @@ function Builder({

Form builder

Turn a team's request into a repeatable starting point.

- +
+ + +
@@ -1904,6 +1956,16 @@ function Builder({ v{f.version} {f.updated_at ? formatDate(f.updated_at) : "—"} + {!trash && ( + + )} {!trash && (
+ {editor.revision > 0 && (
)} + {field.type === "checkbox" && ( + + )} {![ + "checkbox", "multiselect", "section", "static_text", @@ -2547,17 +2634,11 @@ function Builder({ ? undefined : field.type === "number" ? Number(e.target.value) - : field.type === "checkbox" - ? e.target.value === "true" - : e.target.value, + : e.target.value, }) } /> - - {field.type === "checkbox" - ? "Use true or false." - : "Optional initial value."} - + Optional initial value. )} @@ -2884,13 +2965,19 @@ function Builder({ Fixed after the first save. - + + f.state !== "deleted" && + (f.workspace_id || "security") === + (editor.workspace_id || "security"), + ) + .map((f) => f.category)} + onChange={(category) => update({ category })} + /> )} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9782d7d..10dcae6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1489,6 +1489,11 @@ justify-content: space-between; gap: 20px; margin-bottom: 24px; + flex-wrap: wrap; +} +.editor-heading .button-row, +.page-heading .button-row { + flex-wrap: wrap; } .editor-heading h1 { display: flex; diff --git a/package-lock.json b/package-lock.json index 64c2b09..f7c14c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "splunk-actionstack", - "version": "0.5.7", + "version": "0.5.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "splunk-actionstack", - "version": "0.5.7", + "version": "0.5.9", "dependencies": { "lucide-react": "^0.577.0", "react": "^19.2.0", diff --git a/package.json b/package.json index bc6be8e..d153c5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "splunk-actionstack", - "version": "0.5.7", + "version": "0.5.9", "private": true, "type": "module", "scripts": { diff --git a/splunk_actionstack/bin/actionstack/form_transfer.py b/splunk_actionstack/bin/actionstack/form_transfer.py new file mode 100644 index 0000000..50c622c --- /dev/null +++ b/splunk_actionstack/bin/actionstack/form_transfer.py @@ -0,0 +1,42 @@ +"""Validate portable form files without saving or running their lookups.""" +import json +import re +import uuid +from .core import Error, capable, require, clone, validate_definition + +PORTABLE_FIELDS={'title','description','category','icon','accent','intro','fields','mapping'} + +def import_form(service, actor, body): + require(actor,'edit') + if not isinstance(body,dict) or set(body)!={'document','workspace_id'} or not isinstance(body['workspace_id'],str): + raise Error(400,'Choose a form file and destination workspace.') + workspace=service.workspace_records().get(body['workspace_id']) + if not workspace or not service.workspace_allowed(actor,body['workspace_id']): + raise Error(403,'This workspace is unavailable.') + if workspace.get('role_groups') and not capable(actor,'admin') and not set(workspace['role_groups']['admin'])&set(actor['roles']): + raise Error(403,'Only workspace admins can create forms in this workspace.') + document=body['document'] + if not isinstance(document,dict) or set(document)-{'format','format_version','app_version','form'} or document.get('format')!='actionstack-form' or type(document.get('format_version')) is not int or document['format_version']!=1: + raise Error(400,'Choose an ActionStack form export (format version 1).') + try: + if len(json.dumps(document,ensure_ascii=False,allow_nan=False).encode('utf-8'))>200*1024: + raise Error(400,'Choose an ActionStack form JSON file under 200 KB.') + raw=document.get('form') + if not isinstance(raw,dict) or set(raw)-PORTABLE_FIELDS: + raise Error(400,'The form file contains unsupported properties.') + f=clone(raw) + title=f.get('title','') + if not isinstance(title,str): raise Error(400,'Enter a valid form title.') + slug=re.sub('[^a-z0-9]+','-',title.lower()).strip('-') + slug=re.sub('^[^a-z]+','',slug)[:48].rstrip('-') or 'form' + f['id']=slug+'-'+uuid.uuid4().hex[:4] + f['workspace_id']=workspace['id'] + f['access']=clone(workspace.get('default_access') or {'view_roles':[],'submit_roles':[],'edit_roles':actor['roles'],'team_roles':[]}) + f=validate_definition(f,service.roles()) + # Portable files contain inline validation, never policy IDs from another instance. + if f['mapping'].get('policy') not in [None,'none']: + raise Error(400,'Export this form again from the current form builder to include its field validation.') + except (TypeError,ValueError,KeyError,AttributeError,RecursionError): + raise Error(400,'The form file has an invalid structure. Export it again from ActionStack.') + f.update(revision=0,version=0,state='draft') + return f diff --git a/splunk_actionstack/bin/actionstack/service.py b/splunk_actionstack/bin/actionstack/service.py index 68b74cd..2c3b844 100644 --- a/splunk_actionstack/bin/actionstack/service.py +++ b/splunk_actionstack/bin/actionstack/service.py @@ -426,6 +426,9 @@ def dispatch(self,actor,method,path,body=None): if path=='/preferences' and method in ['GET','POST']: return self.preferences(actor,body if method=='POST' else None) if path=='/admin/soar-labels' and method=='GET': return self.available_labels(actor) if path=='/admin/forms/validate' and method=='POST': return self.preview_validation(actor,body) + if path=='/admin/forms/import' and method=='POST': + from .form_transfer import import_form + return import_form(self,actor,body) if path=='/submission-fingerprint' and method=='POST': return self.submission_fingerprint(actor,body) if path=='/workspaces' and method=='GET': return self.list_workspaces(actor) if path=='/admin/workspaces' and method=='GET': return self.list_workspaces(actor,True) diff --git a/splunk_actionstack/default/app.conf b/splunk_actionstack/default/app.conf index 6eb52d6..8b40f62 100644 --- a/splunk_actionstack/default/app.conf +++ b/splunk_actionstack/default/app.conf @@ -8,7 +8,7 @@ label = ActionStack [launcher] author = ActionStack -version = 0.5.7 +version = 0.5.9 description = Role-aware forms and reliable event submission to Splunk SOAR. [package] diff --git a/tests/form-transfer.test.mjs b/tests/form-transfer.test.mjs new file mode 100644 index 0000000..b56736e --- /dev/null +++ b/tests/form-transfer.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { formExport, parseFormFile, MAX_FORM_FILE_BYTES } from '../frontend/src/form-transfer.js'; + +test('portable exports preserve configuration without source identity, access, or history', () => { + const source = { id:'source', workspace_id:'hr', title:'Access request', category:'People', description:'Help', intro:'Start here', icon:'user', accent:'cyan', fields:[{ key:'enabled', type:'checkbox', label:'Enabled', default:true }], mapping:{ label:'actionstack_hr', run_automation:true }, access:{edit_roles:['source_admin']}, revision:8, version:4, state:'published', updated_by:'alice', validation_policy_id:'local-policy', connection:{token:'secret'} }; + const result=formExport(source,'0.5.9'); + assert.deepEqual(Object.keys(result.form).sort(),['title','description','category','icon','accent','intro','fields','mapping'].sort()); + assert.deepEqual(parseFormFile(JSON.stringify(result)),result); + assert.equal(result.form.fields[0].default,true); + assert.equal(result.form.mapping.run_automation,true); + assert.ok(!JSON.stringify(result).includes('secret')); + result.form.fields[0].default=false; + assert.equal(source.fields[0].default,true); +}); + +test('import file parser rejects bad JSON, unsupported formats, and oversized UTF-8 files', () => { + for (const input of ['{', 'null', '[]', '{}', JSON.stringify({format:'actionstack-form',format_version:2}), JSON.stringify({format:'actionstack-form',format_version:true})]) assert.throws(()=>parseFormFile(input)); + assert.throws(()=>parseFormFile('é'.repeat(MAX_FORM_FILE_BYTES/2+1)),/200 KB/); +}); diff --git a/tests/test_field_catalog.py b/tests/test_field_catalog.py index 10de502..6653783 100644 --- a/tests/test_field_catalog.py +++ b/tests/test_field_catalog.py @@ -58,6 +58,20 @@ def test_arrays_reach_soar_as_structured_inputs(self): class LifecycleTests(unittest.TestCase): setUp=fx.ServiceTests.setUp tearDown=fx.ServiceTests.tearDown + def test_checkbox_defaults_survive_publish_and_submission(self): + revision=1 + for default in [True,False]: + with self.subTest(default=default): + field=dict(simple_field('checkbox'),required=False,default=default) + saved=self.svc.save_form(fx.ADMIN,{'form':definition(field),'expected_revision':revision},True) + revision=saved['revision'] + self.assertIs(self.svc.published('block-object')['fields'][0]['default'],default) + for incoming,expected in [({},default),({'target':not default},not default)]: + receipt=self.svc.submit(self.user,{'form_id':'block-object','form_version':saved['version'],'inputs':incoming,'idempotency_key':f'checkbox-{default}-{expected}'}) + record=self.store.get('submissions',receipt['id']) + self.assertEqual(record['status'],'submitted') + self.assertIs(record['artifact_payload']['data']['actionstack']['inputs']['target'],expected) + def test_editor_migration_is_detached_and_preserves_legacy_behavior(self): old=self.svc.published('block-object') f=self.svc.dispatch(fx.ADMIN,'GET','/admin/forms')[0] diff --git a/tests/test_form_transfer.py b/tests/test_form_transfer.py new file mode 100644 index 0000000..5f0a993 --- /dev/null +++ b/tests/test_form_transfer.py @@ -0,0 +1,78 @@ +import unittest +import test_pages as fx +from actionstack.core import Error, clone +from actionstack.form_transfer import PORTABLE_FIELDS + +class FormTransferTests(unittest.TestCase): + setUp=fx.ServiceTests.setUp + tearDown=fx.ServiceTests.tearDown + + def body(self): + source=self.svc.dispatch(fx.ADMIN,'GET','/admin/forms')[0] + return {'workspace_id':'security','document':{'format':'actionstack-form','format_version':1,'app_version':'0.5.9','form':{k:clone(v) for k,v in source.items() if k in PORTABLE_FIELDS}}} + + def imported(self,body,actor=fx.ADMIN): + return self.svc.dispatch(actor,'POST','/admin/forms/import',body) + + def test_import_preserves_configuration_as_unsaved_draft_with_new_identity(self): + body=self.body(); source=clone(body['document']['form']); forms=clone(self.store.list('forms')) + self.factory.reset_mock() + result=self.imported(body) + self.assertNotEqual(result['id'],'block-object') + self.assertRegex(result['id'],r'^block-an-object-[a-f0-9]{4}$') + self.assertEqual((result['state'],result['revision'],result['version']),('draft',0,0)) + for key in ['fields','mapping','category','icon','accent']: self.assertEqual(result[key],source[key]) + self.assertEqual(result['access'],{'view_roles':[],'submit_roles':[],'edit_roles':['admin'],'team_roles':[]}) + self.assertEqual(self.store.list('forms'),forms) + self.assertEqual(self.store.list('submissions'),[]) + self.factory.assert_not_called() + definition={k:v for k,v in result.items() if k not in ['revision','version','state']} + saved=self.svc.save_form(fx.ADMIN,{'form':definition,'expected_revision':0}) + self.assertEqual(saved['state'],'draft') + self.assertEqual(self.svc.published('block-object')['id'],'block-object') + + def test_destination_defaults_and_create_permissions_are_enforced(self): + body=self.body() + self.svc.save_workspace(fx.ADMIN,{'workspace':{'id':'hr','name':'HR','description':'','roles':['team_a'],'state':'active','role_groups':{'viewer':['team_a'],'user':['team_a'],'admin':['team_b']}},'expected_revision':0}) + body['workspace_id']='hr' + for actor in [self.user,fx.actor('editor',['team_a'],['use','edit'])]: + with self.assertRaises(Error) as denied:self.imported(body,actor) + self.assertEqual(denied.exception.status,403) + admin=fx.actor('hr_admin',['team_b'],['use','edit']) + result=self.imported(body,admin) + self.assertEqual(result['access'],self.svc.workspace_records()['hr']['default_access']) + body['workspace_id']='missing' + with self.assertRaises(Error):self.imported(body) + w=self.svc.workspace_records()['hr'] + w['state']='archived';self.store.put('workspaces',w) + body['workspace_id']='hr' + with self.assertRaises(Error):self.imported(body) + + def test_invalid_files_and_source_permissions_are_rejected(self): + base=self.body() + bad=[None,[],{},dict(base['document'],format_version=True),dict(base['document'],format_version=2),dict(base['document'],format='receipt')] + for field,value in [('access',{}),('workspace_id','foreign'),('id','block-object'),('revision',8),('validation_policy_id','missing')]: + document=clone(base['document']);document['form'][field]=value;bad.append(document) + for value in [None,[],{'fields':[]},dict(base['document']['form'],mapping=None),dict(base['document']['form'],fields=[{'key':'bad','type':[],'label':'Bad'}])]: + bad.append(dict(base['document'],form=value)) + bad.append(dict(base['document'],app_version='x'*210000)) + for document in bad: + with self.subTest(document=str(document)[:120]),self.assertRaises(Error) as rejected:self.imported(dict(base,document=document)) + self.assertEqual(rejected.exception.status,400) + self.assertEqual(len(self.store.list('forms')),1) + + def test_lookup_spl_and_conditional_fields_are_validated_without_running_search(self): + body=self.body();form=body['document']['form'] + form['mapping'].pop('enrichment',None);form['mapping'].pop('approval',None) + form['fields']=[{'key':'enabled','type':'checkbox','label':'Enabled','default':True},{'key':'user','type':'lookup','label':'User','show_when':{'field':'enabled','equals':True},'lookup':{'search':'| inputlookup identities | eval label=upper(identity) | table identity label','app':'search','value_field':'identity','label_field':'label','min_chars':3,'debounce_ms':50}}] + self.factory.reset_mock() + result=self.imported(body) + self.assertEqual(result['fields'],form['fields']) + self.factory.assert_not_called() + form['fields'][1]['lookup']['search']='| inputlookup identities | outputlookup stolen' + with self.assertRaises(Error):self.imported(body) + + def test_import_has_same_field_validation_as_form_save(self): + body=self.body() + body['document']['form']['fields'][0]['default']='invalid-choice' + with self.assertRaises(Error):self.imported(body)