This commit is contained in:
James Read 2026-07-08 19:00:51 +00:00 committed by GitHub
commit d0075a7a8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 4622 additions and 713 deletions

116
.github/SECURITY_ADVISORY_DUPLICATES.md vendored Normal file
View File

@ -0,0 +1,116 @@
# Security Advisory Duplicates — Maintainer Guide
This document lists known duplicate security advisory clusters for [OliveTin/OliveTin](https://github.com/OliveTin/OliveTin). When triaging new reports, check here and [open advisories](https://github.com/OliveTin/OliveTin/security/advisories) before accepting.
**Duplicate policy:** the earliest reporter on the canonical advisory receives primary credit. Later reporters are credited on the canonical advisory when closed as duplicates. See [SECURITY.md](../SECURITY.md).
## Triage checklist
1. Search open advisories for the same component and attack path.
2. Match against clusters below.
3. If duplicate: close the newer advisory, link to canonical, add reporter to canonical credits.
4. If unique: accept, patch on a private branch, reassess CVSS with OliveTin context (see SECURITY.md — OliveTin is intentional RCE by design).
5. Merge fix to `next`, publish advisory, credit reporters in advisory body (not commit message).
---
## shellAfterCompleted command injection
**Canonical:** [GHSA-vc6p-m6vx-6cwq](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-vc6p-m6vx-6cwq) — reporter **knight-yagami** (2026-03-04)
Untrusted command output or template variables interpolated into `shellAfterCompleted` and executed via `sh -c`.
| GHSA | Reporter | Status | Notes |
|------|----------|--------|-------|
| GHSA-vc6p-m6vx-6cwq | knight-yagami | closed (canonical) | Original report |
| GHSA-v5gc-hqpq-227p | 0xkakash1 | closed (duplicate) | Output template variant |
| GHSA-m7wr-wj5j-7459 | Ryu7zz | duplicate | Webhook `exec` → output → `shellAfterCompleted` |
| GHSA-cjxm-x848-6vmc | Yesuhei | duplicate | Missing shell safety on after-completion |
| GHSA-j9p9-36jc-2v8w | anushkavirgaonkar | duplicate | Same root cause |
**Fix:** shell-quote `output`/`exitCode` before template render; block `shellAfterCompleted` for webhook-tagged actions.
**CVSS note:** requires admin-configured `shellAfterCompleted` and attacker influence on output — typically PR:H not PR:N.
---
## OAuth2 state map memory exhaustion (DoS)
**Canonical:** [GHSA-xpxj-f2fm-rqch](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-xpxj-f2fm-rqch) — reporter **knight-yagami** (2026-03-04)
Unauthenticated `/oauth/login` grows `registeredStates` without TTL or cap.
| GHSA | Reporter | Status | Notes |
|------|----------|--------|-------|
| GHSA-xpxj-f2fm-rqch | knight-yagami | closed (canonical) | Original report |
| GHSA-cj96-c55v-2f3c | Dredsen | duplicate | Same unbounded map |
**Fix:** TTL sweep (match 15-minute cookie MaxAge), max map size, cleanup on failed callback.
**CVSS note:** unauthenticated DoS — reported 7.5 is appropriate.
---
## URL argument type — unrestricted URI schemes (SSRF / file read)
**Canonical:** [GHSA-45pc-w4ph-hrq4](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-45pc-w4ph-hrq4) — reporter **fg0x0** (2026-03-09)
`url` type accepts `file://`, `gopher://`, etc. Blocked in `shell:` mode but still validated weakly for `exec:` actions.
| GHSA | Reporter | Status | Notes |
|------|----------|--------|-------|
| GHSA-45pc-w4ph-hrq4 | fg0x0 | closed (canonical) | Original report |
| GHSA-cchg-25m4-q6rj | anushkavirgaonkar | duplicate | Same scheme validation gap |
**Fix:** allowlist `http`/`https` in `typeSafetyCheckUrl`.
**CVSS note:** admin must configure `exec:` action passing URL to external tool — PR:H.
---
## Custom `regex:` argument type in shell actions
**Canonical:** [GHSA-xc5w-4v5w-7x65](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-xc5w-4v5w-7x65) — reporter **Ayantaker** (2026-05-06)
`regex:` types not in shell denylist; partial `MatchString` allows injection suffixes.
| GHSA | Reporter | Status | Notes |
|------|----------|--------|-------|
| GHSA-xc5w-4v5w-7x65 | Ayantaker | canonical | Missing denylist entry |
| GHSA-gvxq-7gvp-4ggr | anushkavirgaonkar | duplicate | Unanchored partial match |
**Fix:** deny `regex:` in shell mode; enforce full-string match for custom regex types.
---
## Shell denylist incomplete (post CVE-2026-27626)
**Canonical:** [GHSA-c26w-h42g-jfp9](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-c26w-h42g-jfp9) — reporter **sec-reex** (2026-07-03)
CVE-2026-27626 added `password` to denylist only; `html`, `confirmation`, and choiceless `checkbox` still skip validation and are allowed in `shell:` actions.
**Fix:** extend `checkShellArgumentSafety` denylist.
---
## StartActionAndWait logs ACL bypass
**Canonical:** [GHSA-jm28-2wcr-qf3h](https://github.com/OliveTin/OliveTin/security/advisories/GHSA-jm28-2wcr-qf3h) — reporter **offset** (2026-03-12)
`StartActionAndWait` / `StartActionByGetAndWait` return full log output without `logs` ACL check.
No known duplicates.
**Fix:** apply `isLogEntryAllowed` before returning `LogEntry`.
**CVSS note:** requires authenticated user with `exec` but not `logs` — typically 4.3–5.3 not 6.5.
---
## Easy to confuse (not duplicates)
| Topic | Advisories | Distinction |
|-------|------------|-------------|
| OAuth2 state DoS vs OAuth2 auth bypass | GHSA-xpxj vs GHSA-3v7p | DoS fills state map; bypass spoofs `authHttpHeaderUsername` |
| `shellAfterCompleted` vs direct `shell` injection | GHSA-vc6p vs GHSA-49gm | Second-order via output vs first-order argument injection |
| `ValidateArgumentType` enumeration | GHSA-f637 vs GHSA-x6q3 | Same issue; GHSA-f637 published |

View File

@ -57,6 +57,7 @@ devrun: compile
devcontainer: compile podman-image podman-container devcontainer: compile podman-image podman-container
webui-dist: webui-dist:
$(call delete-files,webui)
$(MAKE) -wC frontend dist $(MAKE) -wC frontend dist
mv frontend/dist webui mv frontend/dist webui
@ -72,4 +73,7 @@ clean:
config-tool: config-tool:
cd service && go run cmd/config-tool/main.go cd service && go run cmd/config-tool/main.go
.PHONY: proto service windows-resources windows-msi devcheck:
python3 scripts/devcheck.py $(ARGS)
.PHONY: proto service windows-resources windows-msi devcheck

View File

@ -133,7 +133,8 @@ actions:
# Docs: https://docs.olivetin.app/args/input_confirmation.html # Docs: https://docs.olivetin.app/args/input_confirmation.html
- title: Delete old backups - title: Delete old backups
icon: ashtonished icon: ashtonished
justification: true # A single space requires justification with no prefilled template (empty disables it).
justification: " "
shell: rm -rf /opt/oliveTinOldBackups/ && sleep 5 shell: rm -rf /opt/oliveTinOldBackups/ && sleep 5
arguments: arguments:
- type: html - type: html
@ -143,6 +144,29 @@ actions:
- type: confirmation - type: confirmation
title: Are you sure?! title: Are you sure?!
# Checklist arguments let users pick multiple predefined options. Selected
# values are passed to the action as a comma-separated string.
#
# Docs: https://docs.olivetin.app/args/input_checklist.html
- title: Backup selected directories
icon: backup
shell: 'echo "Backing up: {{ directories }}"'
arguments:
- name: directories
title: Directories to back up
type: checklist
description: Select one or more directories to include in the backup.
choices:
- title: Documents
value: documents
- title: Photos
value: photos
- title: Music
value: music
- title: Videos
value: videos
default: documents,photos
# This is an action that runs a script included with OliveTin, that will # This is an action that runs a script included with OliveTin, that will
# download themes. You will still need to set theme "themeName" in your config. # download themes. You will still need to set theme "themeName" in your config.
# #
@ -299,6 +323,12 @@ entities:
# Docs: https://docs.olivetin.app/entities/intro.html # Docs: https://docs.olivetin.app/entities/intro.html
- file: entities/servers.yaml - file: entities/servers.yaml
name: server name: server
icon: ssh
properties:
- name: hostname
title: Hostname
- name: ip
title: IP
- file: entities/containers.json - file: entities/containers.json
name: container name: container

View File

@ -73,6 +73,7 @@
** xref:args/regex.adoc[Input: Regex] ** xref:args/regex.adoc[Input: Regex]
** xref:args/password.adoc[Input: Password] ** xref:args/password.adoc[Input: Password]
** xref:args/input_checkbox.adoc[Input: Checkbox/Boolean] ** xref:args/input_checkbox.adoc[Input: Checkbox/Boolean]
** xref:args/input_checklist.adoc[Input: Checklist]
** xref:args/input_dropdown.adoc[Input: Dropdown] ** xref:args/input_dropdown.adoc[Input: Dropdown]
** xref:args/input_datetime.adoc[Input: Date & Time] ** xref:args/input_datetime.adoc[Input: Date & Time]
** xref:args/input_confirmation.adoc[Input: Confirmation] ** xref:args/input_confirmation.adoc[Input: Confirmation]

View File

@ -10,13 +10,13 @@ actions:
- title: Check date and send notification via apprise - title: Check date and send notification via apprise
icon: date icon: date
shell: date shell: date
shellAfterCompleted: "apprise -c /config/apprise.yml -t 'Notification: Backup script completed' -b 'The backup script completed with code {{ exitCode}}. The log is: \n {{ output }} '" shellAfterCompleted: "apprise -c /config/apprise.yml -t 'Notification: Backup script completed' -b \"$(printf 'Backup completed with exit code %s. Log: %s' {{ exitCode }} {{ output }})\""
---- ----
When running shellAfterCompleted, you *cannot* use argument values - they are not passed to the command. However the following special arguments are defined; When running shellAfterCompleted, you *cannot* use argument values - they are not passed to the command. However the following special arguments are defined;
* `{{ exitCode }}` - The exit code of the previous shell command * `{{ exitCode }}` - The exit code of the previous shell command. OliveTin substitutes this with the `EXITCODE` environment variable when running `shellAfterCompleted`, so shell metacharacters in the value cannot break quoting.
* `{{ output }}` - The standard output of the previous shell command * `{{ output }}` - The standard output of the previous shell command. OliveTin substitutes this with the `OUTPUT` environment variable when running `shellAfterCompleted`, so shell metacharacters in command output cannot be executed. You can also reference `$OUTPUT` directly in your `shellAfterCompleted` command. Do not place these placeholders inside single-quoted shell arguments; single quotes prevent `$OUTPUT` and `$EXITCODE` from expanding after substitution.
* `{{ .Arguments.ot_executionTrackingId }}` - The unique execution tracking id for this execution (version 3k; in 2k use `{{ ot_executionTrackingId }}`) * `{{ .Arguments.ot_executionTrackingId }}` - The unique execution tracking id for this execution (version 3k; in 2k use `{{ ot_executionTrackingId }}`)
* `{{ .Arguments.ot_username }}` - The username of the user who started the execution (version 3k; in 2k use `{{ ot_username }}`). May be `guest` or `cron` for unauthenticated or automated runs. * `{{ .Arguments.ot_username }}` - The username of the user who started the execution (version 3k; in 2k use `{{ ot_username }}`). May be `guest` or `cron` for unauthenticated or automated runs.

View File

@ -0,0 +1,80 @@
[#checklist]
= Input: Checklist
The `checklist` type argument renders multiple checkboxes from predefined `choices`. Users can select one or more options, and the selected values are passed to your action as a comma-separated string.
[source,yaml]
----
actions:
- title: Backup selected directories
shell: echo "Backing up: {{ directories }}"
arguments:
- name: directories
title: Directories to back up
type: checklist
choices:
- title: Documents
value: documents
- title: Photos
value: photos
- title: Music
value: music
default: documents,photos
----
When the example above runs with Documents and Photos selected, the shell command becomes:
[source,shell]
----
echo "Backing up: documents,photos"
----
== Select all / Select none
The web interface includes **Select all** and **Select none** controls above the checkbox list.
== Empty selections
If no options are selected, the argument value is an empty string. Use `rejectNull: true` when at least one selection is required.
[source,yaml]
----
arguments:
- name: directories
type: checklist
rejectNull: true
choices:
- value: documents
- value: photos
----
== Choice values
Choice `value` fields must not contain commas, because commas are used to join multiple selections together.
Each `title` is shown in the web interface. If a submitted segment matches a choice `title`, OliveTin maps it to the corresponding `value` before validation, matching the behaviour of xref:args/input_checkbox.adoc[checkbox] arguments with choices.
== Using Entities
Checklist options can be generated from entities, using the same pattern as xref:args/input_dropdown.adoc#args-dropdown-entities[entity-backed dropdowns]. Define one choice template and set `entity` to the entity type name:
[source,yaml]
----
actions:
- title: Restart selected containers
shell: 'docker restart {{ containers }}'
arguments:
- name: containers
title: Containers to restart
type: checklist
entity: container
choices:
- value: '{{ container.Names }}'
title: '{{ container.Names }}'
entities:
- file: entities/containers.json
name: container
----
OliveTin expands the template once per entity instance and renders each result as a checkbox. Selected values are still passed as a comma-separated string.

View File

@ -20,6 +20,7 @@ A full list of argument types are below;
| int | xref:args/input.adoc[Textbox] | Any number, made up of the characters 0 to 9. Negative numbers are not supported. | int | xref:args/input.adoc[Textbox] | Any number, made up of the characters 0 to 9. Negative numbers are not supported.
| url | xref:args/input.adoc[Textbox] | A URL (e.g. https://example.com). Accepts any scheme, including `file://` and `ftp://`. See warning below. | url | xref:args/input.adoc[Textbox] | A URL (e.g. https://example.com). Accepts any scheme, including `file://` and `ftp://`. See warning below.
| confirmation | xref:args/input_confirmation.adoc[Confirmation] | A "hidden" argument that makes the action require a confirmation before launching. | confirmation | xref:args/input_confirmation.adoc[Confirmation] | A "hidden" argument that makes the action require a confirmation before launching.
| checklist | xref:args/input_checklist.adoc[Checklist] | Multiple checkboxes from predefined choices. Selected values are passed as a comma-separated string.
| n/a, but `choices` used | xref:args/input_dropdown.adoc[Dropdown] | A "hidden" argument that makes the action require a confirmation before launching. | n/a, but `choices` used | xref:args/input_dropdown.adoc[Dropdown] | A "hidden" argument that makes the action require a confirmation before launching.
| raw_string_multiline | xref:args/input_textarea.adoc[Textarea] | Anything. This is **dangerous**, as effectively people can type anything they like | raw_string_multiline | xref:args/input_textarea.adoc[Textarea] | Anything. This is **dangerous**, as effectively people can type anything they like
|=== |===
@ -31,4 +32,3 @@ The `url` argument type does not restrict the URL scheme. Users can enter `file:
If your action might be used by untrusted users, validate or filter the URL in your script (e.g. allow only `https://`) before using the value. If your action might be used by untrusted users, validate or filter the URL in your script (e.g. allow only `https://`) before using the value.
==== ====

View File

@ -92,9 +92,9 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
execOnWebhooks: ActionWebhookExecHint[]; execOnWebhooks: ActionWebhookExecHint[];
/** /**
* @generated from field: bool justification = 16; * @generated from field: string justification = 20;
*/ */
justification: boolean; justification: string;
/** /**
* @generated from field: bool has_running_instance = 17; * @generated from field: bool has_running_instance = 17;
@ -247,6 +247,27 @@ export declare type ActionArgumentChoice = Message<"olivetin.api.v1.ActionArgume
*/ */
export declare const ActionArgumentChoiceSchema: GenMessage<ActionArgumentChoice>; export declare const ActionArgumentChoiceSchema: GenMessage<ActionArgumentChoice>;
/**
* @generated from message olivetin.api.v1.EntityRelatedAction
*/
export declare type EntityRelatedAction = Message<"olivetin.api.v1.EntityRelatedAction"> & {
/**
* @generated from field: olivetin.api.v1.Action action = 1;
*/
action?: Action | undefined;
/**
* @generated from field: map<string, string> prefilled_arguments = 2;
*/
prefilledArguments: { [key: string]: string };
};
/**
* Describes the message olivetin.api.v1.EntityRelatedAction.
* Use `create(EntityRelatedActionSchema)` to create a new message.
*/
export declare const EntityRelatedActionSchema: GenMessage<EntityRelatedAction>;
/** /**
* @generated from message olivetin.api.v1.Entity * @generated from message olivetin.api.v1.Entity
*/ */
@ -275,6 +296,16 @@ export declare type Entity = Message<"olivetin.api.v1.Entity"> & {
* @generated from field: map<string, string> fields = 5; * @generated from field: map<string, string> fields = 5;
*/ */
fields: { [key: string]: string }; fields: { [key: string]: string };
/**
* @generated from field: repeated olivetin.api.v1.EntityRelatedAction related_actions = 6;
*/
relatedActions: EntityRelatedAction[];
/**
* @generated from field: string icon = 7;
*/
icon: string;
}; };
/** /**
@ -1894,6 +1925,25 @@ export declare const GetActionBindingResponseSchema: GenMessage<GetActionBinding
* @generated from message olivetin.api.v1.GetEntitiesRequest * @generated from message olivetin.api.v1.GetEntitiesRequest
*/ */
export declare type GetEntitiesRequest = Message<"olivetin.api.v1.GetEntitiesRequest"> & { export declare type GetEntitiesRequest = Message<"olivetin.api.v1.GetEntitiesRequest"> & {
/**
* @generated from field: string entity_type = 1;
*/
entityType: string;
/**
* @generated from field: string filter = 2;
*/
filter: string;
/**
* @generated from field: int32 page = 3;
*/
page: number;
/**
* @generated from field: int32 page_size = 4;
*/
pageSize: number;
}; };
/** /**
@ -1936,6 +1986,21 @@ export declare type EntityDefinition = Message<"olivetin.api.v1.EntityDefinition
* @generated from field: repeated string used_on_dashboards = 3; * @generated from field: repeated string used_on_dashboards = 3;
*/ */
usedOnDashboards: string[]; usedOnDashboards: string[];
/**
* @generated from field: string icon = 4;
*/
icon: string;
/**
* @generated from field: repeated olivetin.api.v1.EntityProperty properties = 5;
*/
properties: EntityProperty[];
/**
* @generated from field: int32 total_instances = 6;
*/
totalInstances: number;
}; };
/** /**
@ -1944,6 +2009,27 @@ export declare type EntityDefinition = Message<"olivetin.api.v1.EntityDefinition
*/ */
export declare const EntityDefinitionSchema: GenMessage<EntityDefinition>; export declare const EntityDefinitionSchema: GenMessage<EntityDefinition>;
/**
* @generated from message olivetin.api.v1.EntityProperty
*/
export declare type EntityProperty = Message<"olivetin.api.v1.EntityProperty"> & {
/**
* @generated from field: string name = 1;
*/
name: string;
/**
* @generated from field: string title = 2;
*/
title: string;
};
/**
* Describes the message olivetin.api.v1.EntityProperty.
* Use `create(EntityPropertySchema)` to create a new message.
*/
export declare const EntityPropertySchema: GenMessage<EntityProperty>;
/** /**
* @generated from message olivetin.api.v1.GetEntityRequest * @generated from message olivetin.api.v1.GetEntityRequest
*/ */
@ -2194,3 +2280,4 @@ export declare const OliveTinApiService: GenService<{
output: typeof EntitySchema; output: typeof EntitySchema;
}, },
}>; }>;

File diff suppressed because one or more lines are too long

View File

@ -61,6 +61,11 @@ const props = defineProps({
type: String, type: String,
required: false, required: false,
default: '' default: ''
},
prefilledArguments: {
type: Object,
required: false,
default: () => ({})
} }
}) })
@ -248,7 +253,16 @@ async function handleClick() {
return return
} }
if (needsArgumentForm(props.actionData)) { if (needsArgumentForm(props.actionData)) {
router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`) const bindingId = props.actionData.bindingId
const prefilled = props.prefilledArguments || {}
if (Object.keys(prefilled).length > 0) {
router.push({
path: `/actionBinding/${bindingId}/argumentForm`,
state: { prefilledArguments: prefilled }
})
} else {
router.push(`/actionBinding/${bindingId}/argumentForm`)
}
} else { } else {
await startAction() await startAction()
} }

View File

@ -0,0 +1,156 @@
<template>
<div class="choice-checklist" :id="`${id}-wrapper`">
<div class="choice-checklist-controls">
<button type="button" class="choice-checklist-control" @click="selectAll">
Select all
</button>
<button type="button" class="choice-checklist-control" @click="selectNone">
Select none
</button>
</div>
<fieldset class="choice-checklist-fieldset">
<legend class="visually-hidden">{{ label || name }}</legend>
<label
v-for="(choice, index) in choices"
:key="choice.value"
class="choice-checklist-item"
:for="`${id}-${index}`"
>
<input
:id="`${id}-${index}`"
type="checkbox"
:checked="isSelected(choice.value)"
@change="handleToggle(choice.value)"
/>
<span>{{ choiceLabel(choice) }}</span>
</label>
</fieldset>
<input
:id="`${id}-value`"
:name="name"
type="text"
class="visually-hidden choice-checklist-value"
:value="modelValue"
:required="required"
tabindex="-1"
aria-hidden="true"
/>
</div>
</template>
<script setup>
import { computed } from 'vue'
import {
allChoiceValues,
choiceLabel,
formatChecklistValue,
parseChecklistValue,
toggleChoice
} from '../utils/choiceChecklistHelpers.js'
const props = defineProps({
id: {
type: String,
required: true
},
name: {
type: String,
required: true
},
label: {
type: String,
default: ''
},
choices: {
type: Array,
required: true
},
modelValue: {
type: String,
default: ''
},
required: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const selectedValues = computed(() => parseChecklistValue(props.modelValue))
function isSelected(value) {
return selectedValues.value.includes(value)
}
function emitSelection(selected) {
emit('update:modelValue', formatChecklistValue(selected))
}
function handleToggle(value) {
emitSelection(toggleChoice(selectedValues.value, value))
}
function selectAll() {
emitSelection(allChoiceValues(props.choices))
}
function selectNone() {
emitSelection([])
}
</script>
<style scoped>
.choice-checklist {
display: flex;
flex-direction: column;
gap: 0.5em;
}
.choice-checklist-controls {
display: flex;
gap: 0.75em;
}
.choice-checklist-control {
background: none;
border: none;
color: inherit;
cursor: pointer;
font: inherit;
padding: 0;
text-decoration: underline;
}
.choice-checklist-fieldset {
border: none;
display: grid;
gap: 0.5em 1em;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
margin: 0;
padding: 0;
}
.choice-checklist-item {
align-items: center;
display: flex;
gap: 0.4em;
margin: 0;
}
.choice-checklist-item input[type="checkbox"] {
margin: 0;
}
.visually-hidden {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
</style>

View File

@ -88,6 +88,7 @@ const rootRef = ref(null)
const searchInputRef = ref(null) const searchInputRef = ref(null)
const isOpen = ref(false) const isOpen = ref(false)
const query = ref('') const query = ref('')
const isUserFiltering = ref(false)
const highlightedIndex = ref(0) const highlightedIndex = ref(0)
const listboxId = computed(() => `${props.id}-listbox`) const listboxId = computed(() => `${props.id}-listbox`)
@ -109,6 +110,10 @@ const placeholderText = computed(() => {
}) })
const filteredChoices = computed(() => { const filteredChoices = computed(() => {
if (!isUserFiltering.value) {
return props.choices
}
const search = query.value.trim().toLowerCase() const search = query.value.trim().toLowerCase()
if (!search) { if (!search) {
return props.choices return props.choices
@ -140,14 +145,23 @@ function syncFromModelValue() {
} }
} }
function selectedChoiceIndex(choices) {
const index = choices.findIndex(choice => choice.value === props.modelValue)
return index >= 0 ? index : 0
}
function openList() { function openList() {
document.dispatchEvent(new CustomEvent(closeOthersEvent, { detail: { id: props.id } })) document.dispatchEvent(new CustomEvent(closeOthersEvent, { detail: { id: props.id } }))
const wasClosed = !isOpen.value
isOpen.value = true isOpen.value = true
highlightedIndex.value = 0 if (wasClosed) {
highlightedIndex.value = selectedChoiceIndex(filteredChoices.value)
}
} }
function closeList() { function closeList() {
isOpen.value = false isOpen.value = false
isUserFiltering.value = false
syncFromModelValue() syncFromModelValue()
} }
@ -164,12 +178,14 @@ function selectChoice(choice) {
function handleFocus() { function handleFocus() {
if (!isOpen.value) { if (!isOpen.value) {
syncFromModelValue() syncFromModelValue()
isUserFiltering.value = false
} }
openList() openList()
} }
function handleSearchInput(event) { function handleSearchInput(event) {
isUserFiltering.value = true
query.value = event.target.value query.value = event.target.value
openList() openList()
highlightedIndex.value = 0 highlightedIndex.value = 0

View File

@ -0,0 +1,181 @@
<template>
<Section :padding="!hasTable">
<template #title>
<span class="section-title-with-icon">
Entity:
<ActionIconGlyph v-if="definition.icon" class="entity-title-icon" :glyph="definition.icon" />
{{ definition.title }}
</span>
</template>
<template v-if="hasTable" #toolbar>
<EntityListFilter v-model="searchText" />
</template>
<p v-if="!hasTable">{{ definition.instances.length }} instances.</p>
<template v-if="hasTable">
<p v-if="tableError" class="table-error padding" role="alert">{{ tableError }}</p>
<EntityInstancesTable
v-else
:instances="tableInstances"
:properties="definition.properties"
:total-instances="totalInstances"
v-model:page="currentPage"
v-model:page-size="pageSize"
/>
</template>
<ul v-else>
<li v-for="inst in definition.instances" :key="inst.uniqueKey">
<router-link :to="entityDetailsRoute(inst)">
{{ inst.title }}
</router-link>
</li>
</ul>
<div v-if="usedDashboards.length > 0" :class="{ padding: hasTable }">
<h3>Used on Dashboards:</h3>
<ul>
<li v-for="dash in usedDashboards" :key="dash">
<template v-if="isEntityDirectory(dash)">
{{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
</template>
<router-link v-else-if="!dash.includes('entity:')" :to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }">
{{ getDashboardTitle(dash) }}
</router-link>
<span v-else>{{ dash }}</span>
</li>
</ul>
</div>
</Section>
</template>
<script setup>
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import Section from 'picocrank/vue/components/Section.vue'
import ActionIconGlyph from './ActionIconGlyph.vue'
import EntityInstancesTable from './EntityInstancesTable.vue'
import EntityListFilter from './EntityListFilter.vue'
import { entityDetailsRoute } from '../utils/entityRoutes.js'
const props = defineProps({
definition: {
type: Object,
required: true
}
})
const searchText = ref('')
const tableInstances = ref([])
const totalInstances = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const tableError = ref('')
let fetchTimer = null
let fetchSequence = 0
const hasTable = computed(() => (props.definition.properties?.length ?? 0) > 0)
const usedDashboards = computed(() => filteredDashboards(props.definition.usedOnDashboards ?? []))
watch(searchText, () => {
currentPage.value = 1
scheduleFetchTableInstances()
})
watch([currentPage, pageSize], () => {
scheduleFetchTableInstances()
})
function filteredDashboards(dashboards) {
return dashboards.filter(d => d && !d.includes('{{'))
}
function isEntityDirectory(dashboardTitle) {
return dashboardTitle.endsWith(' [Entity Directory]')
}
function getDashboardTitle(dashboardTitle) {
if (isEntityDirectory(dashboardTitle)) {
return dashboardTitle.slice(0, -' [Entity Directory]'.length)
}
return dashboardTitle
}
function scheduleFetchTableInstances() {
if (!hasTable.value) {
return
}
if (fetchTimer) {
clearTimeout(fetchTimer)
}
fetchTimer = setTimeout(() => {
fetchTableInstances()
}, 250)
}
async function fetchTableInstances() {
if (!hasTable.value) {
return
}
const requestId = ++fetchSequence
tableError.value = ''
try {
const response = await window.client.getEntities({
entityType: props.definition.title,
filter: searchText.value.trim(),
page: currentPage.value,
pageSize: pageSize.value
})
if (requestId !== fetchSequence) {
return
}
const definition = response.entityDefinitions?.find(def => def.title === props.definition.title)
tableInstances.value = definition?.instances ?? []
totalInstances.value = definition?.totalInstances ?? 0
} catch (err) {
if (requestId !== fetchSequence) {
return
}
console.error('Failed to fetch entity instances:', err)
tableError.value = 'Failed to load entity instances.'
tableInstances.value = []
totalInstances.value = 0
}
}
onMounted(() => {
if (hasTable.value) {
fetchTableInstances()
}
})
onBeforeUnmount(() => {
if (fetchTimer) {
clearTimeout(fetchTimer)
fetchTimer = null
}
})
</script>
<style scoped>
.section-title-with-icon {
display: inline-flex;
align-items: center;
gap: 0.5em;
}
.entity-title-icon {
font-size: 1.2em;
}
.table-error {
color: var(--error, #c00);
}
</style>

View File

@ -0,0 +1,96 @@
<template>
<Table
:data="tableRows"
:headers="headers"
:show-pagination="false"
>
<template #cell-title="{ row, value }">
<router-link :to="entityDetailsRoute(row)">
{{ value }}
</router-link>
</template>
</Table>
<div v-if="totalInstances > 0" class="padding">
<Pagination
:total="totalInstances"
v-model:page="currentPageModel"
v-model:page-size="pageSizeModel"
item-title="entities"
/>
</div>
</template>
<script setup>
import { computed } from 'vue'
import Table from 'picocrank/vue/components/Table.vue'
import Pagination from 'picocrank/vue/components/Pagination.vue'
import { entityDetailsRoute } from '../utils/entityRoutes.js'
const props = defineProps({
instances: {
type: Array,
required: true
},
properties: {
type: Array,
required: true
},
totalInstances: {
type: Number,
default: 0
},
page: {
type: Number,
default: 1
},
pageSize: {
type: Number,
default: 10
}
})
const emit = defineEmits(['update:page', 'update:pageSize'])
const headers = computed(() => {
const propertyHeaders = props.properties.map(property => ({
key: property.name,
label: property.title
}))
return [
{ key: 'title', label: 'Name' },
...propertyHeaders
]
})
const tableRows = computed(() =>
props.instances.map(instance => ({
...instance.fields,
title: instance.title,
type: instance.type,
uniqueKey: instance.uniqueKey
}))
)
const currentPageModel = computed({
get: () => props.page,
set: value => emit('update:page', value)
})
const pageSizeModel = computed({
get: () => props.pageSize,
set: value => emit('update:pageSize', value)
})
</script>
<style scoped>
a {
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>

View File

@ -0,0 +1,65 @@
<template>
<label class="input-with-icons">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor"
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
</svg>
<input
:value="modelValue"
aria-label="Filter entities"
placeholder="Filter entities..."
@input="$emit('update:modelValue', $event.target.value)"
/>
<button title="Clear search filter" :disabled="!modelValue" @click="$emit('update:modelValue', '')">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor"
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
</svg>
</button>
</label>
</template>
<script setup>
defineProps({
modelValue: {
type: String,
default: ''
}
})
defineEmits(['update:modelValue'])
</script>
<style scoped>
.input-with-icons {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--border-color, #ccc);
border-radius: 0.25rem;
background: var(--section-background);
width: 100%;
max-width: 300px;
}
.input-with-icons input {
border: none;
outline: none;
background: transparent;
flex: 1;
color: var(--text-primary);
}
.input-with-icons button {
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
}
.input-with-icons button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
</style>

View File

@ -0,0 +1,54 @@
function parseLegacyChecklistValue(value) {
return value.split(',').map((segment) => segment.trim()).filter((segment) => segment !== '')
}
export function parseChecklistValue(value) {
if (!value || value === '') {
return []
}
const trimmed = value.trim()
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
if (!Array.isArray(parsed)) {
return []
}
return parsed.map((segment) => String(segment).trim()).filter((segment) => segment !== '')
} catch {
return []
}
}
return parseLegacyChecklistValue(value)
}
export function formatChecklistValue(selected) {
if (!Array.isArray(selected) || selected.length === 0) {
return ''
}
return JSON.stringify(selected)
}
export function toggleChoice(selected, value) {
const current = Array.isArray(selected) ? [...selected] : []
const index = current.indexOf(value)
if (index === -1) {
current.push(value)
return current
}
current.splice(index, 1)
return current
}
export function choiceLabel(choice) {
return choice.title || choice.value || ''
}
export function allChoiceValues(choices) {
return choices.map((choice) => choice.value)
}

View File

@ -0,0 +1,46 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
allChoiceValues,
choiceLabel,
formatChecklistValue,
parseChecklistValue,
toggleChoice
} from './choiceChecklistHelpers.js'
const choices = [
{ title: 'Documents', value: 'documents' },
{ title: 'Photos', value: 'photos' }
]
test('parseChecklistValue parses JSON-encoded values', () => {
assert.deepEqual(parseChecklistValue('["documents","photos"]'), ['documents', 'photos'])
assert.deepEqual(parseChecklistValue('["kitchen,bedroom","hallway"]'), ['kitchen,bedroom', 'hallway'])
assert.deepEqual(parseChecklistValue(''), [])
})
test('parseChecklistValue accepts legacy comma-delimited values', () => {
assert.deepEqual(parseChecklistValue('documents,photos'), ['documents', 'photos'])
assert.deepEqual(parseChecklistValue('documents, photos'), ['documents', 'photos'])
})
test('formatChecklistValue joins selected values as JSON', () => {
assert.equal(formatChecklistValue(['documents', 'photos']), '["documents","photos"]')
assert.equal(formatChecklistValue(['kitchen,bedroom']), '["kitchen,bedroom"]')
assert.equal(formatChecklistValue([]), '')
})
test('toggleChoice adds and removes values', () => {
assert.deepEqual(toggleChoice([], 'documents'), ['documents'])
assert.deepEqual(toggleChoice(['documents'], 'photos'), ['documents', 'photos'])
assert.deepEqual(toggleChoice(['documents', 'photos'], 'documents'), ['photos'])
})
test('choiceLabel prefers title over value', () => {
assert.equal(choiceLabel(choices[0]), 'Documents')
assert.equal(choiceLabel({ value: 'music' }), 'music')
})
test('allChoiceValues returns every choice value', () => {
assert.deepEqual(allChoiceValues(choices), ['documents', 'photos'])
})

View File

@ -0,0 +1,9 @@
export function entityDetailsRoute (entity) {
return {
name: 'EntityDetails',
params: {
entityType: entity.type,
entityKey: entity.uniqueKey
}
}
}

View File

@ -0,0 +1,15 @@
export function applyArgumentTemplate(template, args) {
if (!template) {
return ''
}
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, name) => args[name] ?? '')
}
export function actionRequiresJustification(justification) {
return (justification ?? '').length > 0
}
export function actionJustificationTemplate(justification) {
return justification ?? ''
}

View File

@ -1,3 +1,5 @@
import { actionRequiresJustification } from './justificationTemplate.js'
export function needsArgumentForm (action) { export function needsArgumentForm (action) {
return (action?.arguments?.length > 0) || action?.justification return (action?.arguments?.length > 0) || actionRequiresJustification(action?.justification)
} }

View File

@ -0,0 +1,21 @@
export function readPrefilledArgumentsFromNavigation() {
const state = window.history.state
if (state?.prefilledArguments && typeof state.prefilledArguments === 'object') {
return { ...state.prefilledArguments }
}
return {}
}
export function getInitialArgumentValue(paramName, prefilledArguments = {}) {
const safePrefilledArguments = prefilledArguments && typeof prefilledArguments === 'object'
? prefilledArguments
: {}
if (Object.prototype.hasOwnProperty.call(safePrefilledArguments, paramName)) {
return safePrefilledArguments[paramName]
}
const params = new URLSearchParams(window.location.search)
return params.get(paramName)
}

View File

@ -0,0 +1,39 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from './prefilledArguments.js'
test('readPrefilledArgumentsFromNavigation returns navigation state values', () => {
const originalState = window.history.state
window.history.replaceState({ prefilledArguments: { ansible_host: '10.0.0.1' } }, '')
assert.deepEqual(readPrefilledArgumentsFromNavigation(), { ansible_host: '10.0.0.1' })
window.history.replaceState(originalState, '')
})
test('getInitialArgumentValue prefers navigation state over query params', () => {
const originalState = window.history.state
const originalSearch = window.location.search
window.history.replaceState({ prefilledArguments: { ansible_host: '10.0.0.1' } }, '')
window.history.replaceState(window.history.state, '', '?ansible_host=10.0.0.2')
assert.equal(getInitialArgumentValue('ansible_host', readPrefilledArgumentsFromNavigation()), '10.0.0.1')
window.history.replaceState(originalState, '')
window.history.replaceState(window.history.state, '', originalSearch || '/')
})
test('getInitialArgumentValue falls back to query params when state is absent', () => {
const originalState = window.history.state
const originalSearch = window.location.search
window.history.replaceState({}, '')
window.history.replaceState(window.history.state, '', '?ansible_host=10.0.0.2')
assert.equal(getInitialArgumentValue('ansible_host', readPrefilledArgumentsFromNavigation()), '10.0.0.2')
window.history.replaceState(originalState, '')
window.history.replaceState(window.history.state, '', originalSearch || '/')
})

View File

@ -1,4 +1,5 @@
import { needsArgumentForm } from './needsArgumentForm.js' import { needsArgumentForm } from './needsArgumentForm.js'
import { actionRequiresJustification } from './justificationTemplate.js'
const nonStorableArgumentTypes = new Set([ const nonStorableArgumentTypes = new Set([
'password', 'password',
@ -17,7 +18,7 @@ export function logEntryArgumentsToStartActionArgs (logEntry) {
} }
export function rerunNeedsArgumentForm (action, logEntry) { export function rerunNeedsArgumentForm (action, logEntry) {
if (action?.justification && !logEntry?.justification) { if (actionRequiresJustification(action?.justification) && !logEntry?.justification) {
return true return true
} }
@ -60,7 +61,7 @@ export function buildRerunStartActionArgs (bindingId, logEntry, action) {
arguments: logEntryArgumentsToStartActionArgs(logEntry) arguments: logEntryArgumentsToStartActionArgs(logEntry)
} }
if (action?.justification && logEntry?.justification) { if (actionRequiresJustification(action?.justification) && logEntry?.justification) {
startActionArgs.justification = logEntry.justification startActionArgs.justification = logEntry.justification
} }

View File

@ -75,7 +75,7 @@ test('rerunNeedsArgumentForm can start directly when stored args are complete',
}) })
test('rerunNeedsArgumentForm opens the form when justification is missing', () => { test('rerunNeedsArgumentForm opens the form when justification is missing', () => {
const action = { justification: true, arguments: [] } const action = { justification: ' ', arguments: [] }
assert.equal(rerunNeedsArgumentForm(action, {}), true) assert.equal(rerunNeedsArgumentForm(action, {}), true)
assert.equal( assert.equal(
@ -90,7 +90,7 @@ test('buildRerunStartActionArgs includes stored justification', () => {
arguments: [{ name: 'host', value: 'db-1' }], arguments: [{ name: 'host', value: 'db-1' }],
justification: 'maintenance window' justification: 'maintenance window'
}, { }, {
justification: true, justification: ' ',
arguments: [{ name: 'host', type: 'ascii_identifier' }] arguments: [{ name: 'host', type: 'ascii_identifier' }]
}), }),
{ {

View File

@ -96,7 +96,9 @@
<td class="duration">{{ formatExecutionDuration(log) }}</td> <td class="duration">{{ formatExecutionDuration(log) }}</td>
<td> <td>
<router-link :to="`/logs/${log.executionTrackingId}`"> <router-link :to="`/logs/${log.executionTrackingId}`">
{{ log.executionTrackingId }} <LogActionTitle :justification="log.justification">
{{ log.executionTrackingId }}
</LogActionTitle>
</router-link> </router-link>
</td> </td>
<td class="tags"> <td class="tags">
@ -134,6 +136,7 @@ import Section from 'picocrank/vue/components/Section.vue'
import ActionIconGlyph from '../components/ActionIconGlyph.vue' import ActionIconGlyph from '../components/ActionIconGlyph.vue'
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue' import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue' import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue'
import LogActionTitle from '../components/LogActionTitle.vue'
import { HugeiconsIcon } from '@hugeicons/vue' import { HugeiconsIcon } from '@hugeicons/vue'
import { DashboardSquare01Icon, WorkoutRunIcon } from '@hugeicons/core-free-icons' import { DashboardSquare01Icon, WorkoutRunIcon } from '@hugeicons/core-free-icons'
import { requestReconnectNow } from '../../../js/websocket.js' import { requestReconnectNow } from '../../../js/websocket.js'
@ -162,7 +165,8 @@ const filteredLogs = computed(() => {
const searchLower = searchText.value.toLowerCase() const searchLower = searchText.value.toLowerCase()
return logs.value.filter(log => return logs.value.filter(log =>
log.executionTrackingId.toLowerCase().includes(searchLower) || log.executionTrackingId.toLowerCase().includes(searchLower) ||
log.actionTitle.toLowerCase().includes(searchLower) log.actionTitle.toLowerCase().includes(searchLower) ||
(log.justification || '').toLowerCase().includes(searchLower)
) )
}) })

View File

@ -1,16 +1,30 @@
<template> <template>
<section id = "argument-popup"> <section id = "argument-popup">
<div class="section-header"> <div class="section-header">
<h2>Start action: {{ title }}</h2> <h2>
<span class="section-title-with-icon">
Start action:
<router-link
:to="`/action/${bindingId}`"
class="action-details-title-link"
>
<ActionIconGlyph v-if="icon" class="action-title-icon" :glyph="icon" />
{{ title }}
</router-link>
</span>
</h2>
</div> </div>
<div class="section-content padding"> <div class="section-content padding">
<form @submit="handleSubmit"> <form @submit="handleSubmit">
<template v-if="actionArguments.length > 0"> <template v-if="actionArguments.length > 0">
<template v-for="arg in actionArguments" :key="arg.name"> <template v-for="arg in actionArguments" :key="arg.name">
<label :for="arg.name"> <label v-if="arg.type !== 'checklist'" :for="arg.name">
{{ formatLabel(arg.title) }} {{ formatLabel(arg.title) }}
</label> </label>
<div v-else class="argument-label">
{{ formatLabel(arg.title) }}
</div>
<datalist v-if="(arg.suggestions && Object.keys(arg.suggestions).length > 0) || getBrowserSuggestions(arg).length > 0" :id="`${arg.name}-choices`"> <datalist v-if="(arg.suggestions && Object.keys(arg.suggestions).length > 0) || getBrowserSuggestions(arg).length > 0" :id="`${arg.name}-choices`">
<option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key"> <option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
@ -25,6 +39,10 @@
:choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required" :choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
@update:model-value="handleChoiceUpdate(arg, $event)" /> @update:model-value="handleChoiceUpdate(arg, $event)" />
<ChoiceChecklist v-else-if="arg.type === 'checklist'" :id="arg.name" :name="arg.name"
:label="arg.title" :choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
@update:model-value="handleChoiceUpdate(arg, $event)" />
<component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name" <component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name"
:value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)" :value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
:checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined" :checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
@ -40,7 +58,7 @@
<template v-if="justificationRequired"> <template v-if="justificationRequired">
<label for="justification">Justification:</label> <label for="justification">Justification:</label>
<input id="justification" name="justification" type="text" v-model="justificationValue" required /> <input id="justification" name="justification" type="text" :value="justificationValue" required @input="handleJustificationInput" />
</template> </template>
<div v-if="actionArguments.length === 0 && !justificationRequired"> <div v-if="actionArguments.length === 0 && !justificationRequired">
@ -61,10 +79,18 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount, onUnmounted, nextTick } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { requestReconnectNow } from '../../../js/websocket.js' import { requestReconnectNow } from '../../../js/websocket.js'
import ChoiceCombobox from '../components/ChoiceCombobox.vue' import ChoiceCombobox from '../components/ChoiceCombobox.vue'
import ChoiceChecklist from '../components/ChoiceChecklist.vue'
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
import {
actionJustificationTemplate,
actionRequiresJustification,
applyArgumentTemplate
} from '../utils/justificationTemplate.js'
import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from '../utils/prefilledArguments.js'
const router = useRouter() const router = useRouter()
@ -80,8 +106,11 @@ const formErrors = ref({})
const actionArguments = ref([]) const actionArguments = ref([])
const popupOnStart = ref('') const popupOnStart = ref('')
const formReady = ref(false) const formReady = ref(false)
const justificationRequired = ref(false) const justificationConfig = ref('')
const justificationValue = ref('') const justificationValue = ref('')
const justificationEditedManually = ref(false)
const justificationRequired = computed(() => actionRequiresJustification(justificationConfig.value))
const justificationTemplate = computed(() => actionJustificationTemplate(justificationConfig.value))
let isComponentMounted = true let isComponentMounted = true
// Computed properties // Computed properties
@ -109,18 +138,21 @@ async function setup() {
icon.value = action.icon icon.value = action.icon
popupOnStart.value = action.popupOnStart || '' popupOnStart.value = action.popupOnStart || ''
actionArguments.value = action.arguments || [] actionArguments.value = action.arguments || []
justificationRequired.value = action.justification || false justificationConfig.value = action.justification || ''
justificationValue.value = '' justificationValue.value = ''
justificationEditedManually.value = false
argValues.value = {} argValues.value = {}
formErrors.value = {} formErrors.value = {}
confirmationChecked.value = false confirmationChecked.value = false
hasConfirmation.value = false hasConfirmation.value = false
// Initialize values from query params or defaults const prefilledArguments = readPrefilledArgumentsFromNavigation()
// Initialize values from navigation state, query params, or defaults
actionArguments.value.forEach(arg => { actionArguments.value.forEach(arg => {
if (arg.type === 'confirmation') { if (arg.type === 'confirmation') {
hasConfirmation.value = true hasConfirmation.value = true
const paramValue = getQueryParamValue(arg.name) const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
let checkedValue = false let checkedValue = false
if (paramValue !== null) { if (paramValue !== null) {
checkedValue = paramValue === '1' || paramValue === 'true' || paramValue === true checkedValue = paramValue === '1' || paramValue === 'true' || paramValue === true
@ -130,7 +162,7 @@ async function setup() {
argValues.value[arg.name] = checkedValue argValues.value[arg.name] = checkedValue
confirmationChecked.value = checkedValue confirmationChecked.value = checkedValue
} else { } else {
const paramValue = getQueryParamValue(arg.name) const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
if (arg.type === 'checkbox') { if (arg.type === 'checkbox') {
// For checkboxes, handle boolean default values properly // For checkboxes, handle boolean default values properly
if (paramValue !== null) { if (paramValue !== null) {
@ -158,16 +190,13 @@ async function setup() {
formReady.value = true formReady.value = true
document.body.setAttribute('loaded-argument-form', props.bindingId) document.body.setAttribute('loaded-argument-form', props.bindingId)
} }
updateJustificationFromTemplate()
} catch (err) { } catch (err) {
console.error('Failed to load argument form:', err) console.error('Failed to load argument form:', err)
} }
} }
function getQueryParamValue(paramName) {
const params = new URLSearchParams(window.location.search.substring(1))
return params.get(paramName)
}
function formatLabel(title) { function formatLabel(title) {
const lastChar = title.charAt(title.length - 1) const lastChar = title.charAt(title.length - 1)
if (lastChar === '?' || lastChar === '.' || lastChar === ':') { if (lastChar === '?' || lastChar === '.' || lastChar === ':') {
@ -222,10 +251,16 @@ function getArgumentValue(arg) {
return argValues.value[arg.name] || '' return argValues.value[arg.name] || ''
} }
function handleJustificationInput(event) {
justificationValue.value = event.target.value
justificationEditedManually.value = true
}
function handleInput(arg, event) { function handleInput(arg, event) {
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
argValues.value[arg.name] = value argValues.value[arg.name] = value
updateUrlWithArg(arg.name, value) updateUrlWithArg(arg.name, value)
updateJustificationFromTemplate()
} }
function handleChange(arg, event) { function handleChange(arg, event) {
@ -238,10 +273,19 @@ function handleChange(arg, event) {
validateArgument(arg, event.target.value) validateArgument(arg, event.target.value)
} }
function getValidationElement(arg) {
if (arg.type === 'checklist') {
return document.getElementById(`${arg.name}-value`)
}
return document.getElementById(arg.name)
}
function handleChoiceUpdate(arg, value) { function handleChoiceUpdate(arg, value) {
argValues.value[arg.name] = value argValues.value[arg.name] = value
updateUrlWithArg(arg.name, value) updateUrlWithArg(arg.name, value)
validateArgument(arg, value) validateArgument(arg, value)
updateJustificationFromTemplate()
} }
async function validateArgument(arg, value) { async function validateArgument(arg, value) {
@ -251,7 +295,7 @@ async function validateArgument(arg, value) {
// Skip validation for datetime - backend will handle mangling values without seconds // Skip validation for datetime - backend will handle mangling values without seconds
if (arg.type === 'datetime') { if (arg.type === 'datetime') {
const inputElement = document.getElementById(arg.name) const inputElement = getValidationElement(arg)
if (inputElement) { if (inputElement) {
inputElement.setCustomValidity('') inputElement.setCustomValidity('')
} }
@ -261,7 +305,7 @@ async function validateArgument(arg, value) {
// Skip validation for checkbox and confirmation - they're always valid // Skip validation for checkbox and confirmation - they're always valid
if (arg.type === 'checkbox' || arg.type === 'confirmation') { if (arg.type === 'checkbox' || arg.type === 'confirmation') {
const inputElement = document.getElementById(arg.name) const inputElement = getValidationElement(arg)
if (inputElement) { if (inputElement) {
inputElement.setCustomValidity('') inputElement.setCustomValidity('')
} }
@ -279,8 +323,7 @@ async function validateArgument(arg, value) {
const validation = await window.client.validateArgumentType(validateArgumentTypeArgs) const validation = await window.client.validateArgumentType(validateArgumentTypeArgs)
// Get the input element to set custom validity const inputElement = getValidationElement(arg)
const inputElement = document.getElementById(arg.name)
if (validation.valid) { if (validation.valid) {
delete formErrors.value[arg.name] delete formErrors.value[arg.name]
@ -297,8 +340,7 @@ async function validateArgument(arg, value) {
} }
} catch (err) { } catch (err) {
console.warn('Validation failed:', err) console.warn('Validation failed:', err)
// On error, clear any custom validity const inputElement = getValidationElement(arg)
const inputElement = document.getElementById(arg.name)
if (inputElement) { if (inputElement) {
inputElement.setCustomValidity('') inputElement.setCustomValidity('')
} }
@ -344,21 +386,39 @@ function formatArgumentValueForApi(arg, rawValue) {
return rawValue ?? '' return rawValue ?? ''
} }
function getArgumentValues() { function getSelectedArgumentEntries() {
const ret = [] const entries = []
for (const arg of actionArguments.value) { for (const arg of actionArguments.value) {
if (!shouldSendArgument(arg)) { if (!shouldSendArgument(arg)) {
continue continue
} }
ret.push({ entries.push({
name: arg.name, name: arg.name,
value: formatArgumentValueForApi(arg, argValues.value[arg.name]) value: formatArgumentValueForApi(arg, argValues.value[arg.name])
}) })
} }
return ret return entries
}
function getArgumentValues() {
return getSelectedArgumentEntries().map(({ name, value }) => ({ name, value }))
}
function getArgumentMapForTemplate() {
return Object.fromEntries(
getSelectedArgumentEntries().map(({ name, value }) => [name, value])
)
}
function updateJustificationFromTemplate() {
if (!justificationTemplate.value || justificationEditedManually.value) {
return
}
justificationValue.value = applyArgumentTemplate(justificationTemplate.value, getArgumentMapForTemplate())
} }
function getUniqueId() { function getUniqueId() {
@ -393,7 +453,7 @@ function saveBrowserSuggestions() {
const value = argValues.value[arg.name] const value = argValues.value[arg.name]
// Only save non-empty values for non-checkbox/confirmation/password types // Only save non-empty values for non-checkbox/confirmation/password types
if (value && value !== '' && arg.type !== 'checkbox' && arg.type !== 'confirmation' && arg.type !== 'password') { if (value && value !== '' && arg.type !== 'checkbox' && arg.type !== 'confirmation' && arg.type !== 'checklist' && arg.type !== 'password') {
try { try {
const key = `olivetin-suggestions-${arg.suggestionsBrowserKey}` const key = `olivetin-suggestions-${arg.suggestionsBrowserKey}`
const stored = localStorage.getItem(key) const stored = localStorage.getItem(key)
@ -467,7 +527,7 @@ async function handleSubmit(event) {
for (const arg of actionArguments.value) { for (const arg of actionArguments.value) {
const value = argValues.value[arg.name] const value = argValues.value[arg.name]
const inputElement = document.getElementById(arg.name) const inputElement = getValidationElement(arg)
if (arg.required && (!value || value === '')) { if (arg.required && (!value || value === '')) {
formErrors.value[arg.name] = 'This field is required' formErrors.value[arg.name] = 'This field is required'
@ -484,6 +544,11 @@ async function handleSubmit(event) {
return return
} }
if (Object.keys(formErrors.value).length > 0) {
console.log('argument form has validation errors')
return
}
const argvs = getArgumentValues() const argvs = getArgumentValues()
console.log('argument form has elements that passed validation') console.log('argument form has elements that passed validation')
@ -546,6 +611,27 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
.section-title-with-icon {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.action-title-icon {
font-size: 1.5rem;
}
.action-details-title-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--link-color, #0066cc);
text-decoration: underline;
}
.action-details-title-link:hover {
color: var(--link-hover-color, #004499);
}
form { form {
grid-template-columns: max-content auto auto; grid-template-columns: max-content auto auto;

View File

@ -9,36 +9,18 @@
</p> </p>
</Section> </Section>
<template v-else> <template v-else>
<Section v-for="def in entityDefinitions" :key="def.title" :title="'Entity: ' + def.title "> <EntityDefinitionSection
<p>{{ def.instances.length }} instances.</p> v-for="def in entityDefinitions"
:key="def.title"
<ul> :definition="def"
<li v-for="inst in def.instances" :key="inst.uniqueKey"> />
<router-link :to="{ name: 'EntityDetails', params: { entityType: inst.type, entityKey: inst.uniqueKey } }">
{{ inst.title }}
</router-link>
</li>
</ul>
<h3>Used on Dashboards:</h3>
<ul>
<li v-for="dash in filteredDashboards(def.usedOnDashboards)" :key="dash">
<template v-if="isEntityDirectory(dash)">
{{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
</template>
<router-link v-else-if="!dash.includes('entity:')" :to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }">
{{ getDashboardTitle(dash) }}
</router-link>
<span v-else>{{ dash }}</span>
</li>
</ul>
</Section>
</template> </template>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import Section from 'picocrank/vue/components/Section.vue' import Section from 'picocrank/vue/components/Section.vue'
import EntityDefinitionSection from '../components/EntityDefinitionSection.vue'
const definitionsLoaded = ref(false) const definitionsLoaded = ref(false)
const entityDefinitions = ref([]) const entityDefinitions = ref([])
@ -63,22 +45,7 @@
} }
} }
function filteredDashboards(dashboards) { onMounted(() => {
return dashboards.filter(d => d && !d.includes('{{')) fetchEntities()
}
function isEntityDirectory(dashboardTitle) {
return dashboardTitle.endsWith(' [Entity Directory]')
}
function getDashboardTitle(dashboardTitle) {
if (isEntityDirectory(dashboardTitle)) {
return dashboardTitle.slice(0, -' [Entity Directory]'.length)
}
return dashboardTitle
}
onMounted(() => {
fetchEntities()
}) })
</script> </script>

View File

@ -1,5 +1,12 @@
<template> <template>
<Section title="Entity Details"> <Section>
<template #title>
<span class="section-title-with-icon">
Entity Details:
<ActionIconGlyph v-if="entityIcon" class="entity-title-icon" :glyph="entityIcon" />
<span v-if="entityDetails?.title">{{ entityDetails.title }}</span>
</span>
</template>
<template #toolbar> <template #toolbar>
<button @click="goBack" class="back-button"> <button @click="goBack" class="back-button">
<HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" /> <HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" />
@ -22,37 +29,51 @@
<template v-if="entityDetails.fields"> <template v-if="entityDetails.fields">
<template v-for="(value, key) in entityDetails.fields" :key="key"> <template v-for="(value, key) in entityDetails.fields" :key="key">
<dt>{{ key }}</dt> <dt>{{ key }}</dt>
<dd v-html="value"></dd> <dd>{{ value }}</dd>
</template> </template>
</template> </template>
</dl> </dl>
<p v-if="!entityDetails.title && (!entityDetails.fields || Object.keys(entityDetails.fields).length === 0)">No details available for this entity.</p> <p v-if="!entityDetails.title && (!entityDetails.fields || Object.keys(entityDetails.fields).length === 0)">No details available for this entity.</p>
<hr />
<h3>Dashboard Entity Directories</h3>
<div v-if="filteredDirectories.length > 0" class="directories-section">
<ul class="directory-list">
<li v-for="(directory, idx) in filteredDirectories" :key="idx">
<router-link
:to="{
name: 'Dashboard',
params: {
title: directory,
entityType: entityType,
entityKey: entityKey
}
}">
{{ directory }}
</router-link>
</li>
</ul>
</div>
<p v-else>No directories found for this entity.
<a href = "https://docs.olivetin.app/dashboards/entity-directories.html" target = "_blank">Learn more</a>
</p>
</template> </template>
</Section> </Section>
<Section v-if="entityDetails" title="Dashboard Entity Directories">
<div v-if="filteredDirectories.length > 0" class="directories-section">
<ul class="directory-list">
<li v-for="(directory, idx) in filteredDirectories" :key="idx">
<router-link
:to="{
name: 'Dashboard',
params: {
title: directory,
entityType: entityType,
entityKey: entityKey
}
}">
{{ directory }}
</router-link>
</li>
</ul>
</div>
<p v-else>No directories found for this entity.
<a href="https://docs.olivetin.app/dashboards/entity-directories.html" target="_blank" rel="noopener noreferrer">Learn more</a>
</p>
</Section>
<section v-if="entityDetails && relatedActions.length > 0" class="transparent">
<div class="dashboard-row">
<fieldset>
<legend class="visually-hidden">Related actions</legend>
<template v-for="(related, idx) in relatedActions" :key="related.action?.bindingId || idx">
<ActionButton
v-if="related.action"
:action-data="related.action"
:prefilled-arguments="related.prefilledArguments"
/>
</template>
</fieldset>
</div>
</section>
</template> </template>
<script setup> <script setup>
@ -61,6 +82,8 @@
import { HugeiconsIcon } from '@hugeicons/vue' import { HugeiconsIcon } from '@hugeicons/vue'
import { ArrowLeftIcon } from '@hugeicons/core-free-icons' import { ArrowLeftIcon } from '@hugeicons/core-free-icons'
import Section from 'picocrank/vue/components/Section.vue' import Section from 'picocrank/vue/components/Section.vue'
import ActionButton from '../ActionButton.vue'
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
const router = useRouter() const router = useRouter()
const entityDetails = ref(null) const entityDetails = ref(null)
@ -77,6 +100,10 @@
return entityDetails.value.directories.filter(d => d) return entityDetails.value.directories.filter(d => d)
}) })
const relatedActions = computed(() => entityDetails.value?.relatedActions ?? [])
const entityIcon = computed(() => entityDetails.value?.icon ?? '')
function goBack() { function goBack() {
router.push({ name: 'Entities' }) router.push({ name: 'Entities' })
} }
@ -121,11 +148,6 @@
box-shadow: 0 0 .5em rgba(0, 0, 0, 0.15); box-shadow: 0 0 .5em rgba(0, 0, 0, 0.15);
} }
.directories-section h3 {
margin-bottom: 0.5em;
font-size: 1.1em;
}
.directory-list a { .directory-list a {
text-decoration: none; text-decoration: none;
padding: 0.5em; padding: 0.5em;
@ -149,9 +171,22 @@
opacity: 0.8; opacity: 0.8;
} }
hr { .section-title-with-icon {
border: 0; display: inline-flex;
border-top: 1px solid var(--border-color, #ccc); align-items: center;
gap: 0.5em;
}
.entity-title-icon {
font-size: 1.2em;
}
fieldset {
display: grid;
grid-template-columns: repeat(auto-fit, 180px);
grid-auto-rows: 1fr;
justify-content: center;
place-items: stretch;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@ -164,11 +199,6 @@ hr {
background-color: var(--bg-hover, #222); background-color: var(--bg-hover, #222);
} }
.directories-section {
border-top-color: var(--border-color, #333);
}
.directory-list a:hover { .directory-list a:hover {
background-color: var(--bg-hover, #222); background-color: var(--bg-hover, #222);
} }

View File

@ -1,8 +1,12 @@
default: test-install test-run default: test-install prep test-run
test-install: test-install:
npm install --no-fund npm install --no-fund
prep:
$(MAKE) -wC .. webui-dist
$(MAKE) -wC ../service compile-currentenv
test-run: test-run:
# GitHub Actions fails badly on the default timeout of 2000ms # GitHub Actions fails badly on the default timeout of 2000ms
npx mocha tests --recursive -t 10000 npx mocha tests --recursive -t 10000
@ -24,4 +28,4 @@ getsnapshot:
rm -rf /opt/OliveTin-snapshot/* rm -rf /opt/OliveTin-snapshot/*
gh run download -D /opt/OliveTin-snapshot/ gh run download -D /opt/OliveTin-snapshot/
.PHONY: default find-flakey-tests find-flakey-tests-inf .PHONY: default find-flakey-tests find-flakey-tests-inf prep

View File

@ -7,7 +7,9 @@ defaultPopupOnStart: execution-dialog
actions: actions:
- title: Test checkbox argument - title: Test checkbox argument
shell: "echo 'Checkbox value: {{ confirm }}'" exec:
- echo
- "Checkbox value: {{ confirm }}"
icon: ping icon: ping
arguments: arguments:
- name: confirm - name: confirm
@ -15,5 +17,3 @@ actions:
type: checkbox type: checkbox
description: "When checked: 1, when unchecked: 0" description: "When checked: 1, when unchecked: 0"
default: false default: false

View File

@ -0,0 +1,178 @@
import { describe, it, before, after } from 'mocha'
import { expect } from 'chai'
import { By, Condition } from 'selenium-webdriver'
import {
DEFAULT_UI_WAIT_MS,
getRootAndWait,
getActionButton,
takeScreenshotOnFailure,
getTerminalBuffer,
waitForArgumentFormPage,
waitForArgumentFormReady,
waitForLogsPage,
waitForExecutionComplete,
} from '../../lib/elements.js'
async function openChecklistArgumentForm(actionTitle = 'Test checklist argument') {
await getRootAndWait()
const btn = await getActionButton(webdriver, actionTitle)
await btn.click()
await waitForArgumentFormPage()
await waitForArgumentFormReady()
}
async function submitChecklistForm() {
const submitButton = await webdriver.findElement(By.css('button[name="start"]'))
await submitButton.click()
}
async function pollTerminal(matcher, timeoutMs = DEFAULT_UI_WAIT_MS) {
await webdriver.wait(
new Condition('wait for terminal output', async () => {
try {
const terminalReady = await webdriver.executeScript(`
return !!(window.terminal && window.terminal.getBufferAsString);
`)
if (!terminalReady) {
return false
}
const output = await getTerminalBuffer()
if (!output) {
return false
}
return matcher(output.trim())
} catch (e) {
return false
}
}),
timeoutMs
)
}
async function waitForTerminalOutput(expectedValue, label = 'Selected segments') {
await pollTerminal(
(output) => output.includes(`${label}: ${expectedValue}`),
DEFAULT_UI_WAIT_MS
)
}
async function waitForTerminalOutputPattern(pattern) {
await pollTerminal(
(output) => pattern.test(output),
DEFAULT_UI_WAIT_MS
)
}
async function waitForChecklistValue(expectedValue) {
await webdriver.wait(
new Condition('wait for checklist hidden value', async () => {
const valueInput = await webdriver.findElement(By.css('.choice-checklist > input'))
return (await valueInput.getAttribute('value')) === expectedValue
}),
DEFAULT_UI_WAIT_MS
)
}
async function getCheckboxByValueIndex(index) {
const checkboxes = await webdriver.findElements(
By.css('.choice-checklist-item input[type="checkbox"]')
)
return checkboxes[index]
}
describe('config: checklist', function () {
this.timeout(10000)
before(async function () {
await runner.start('checklist')
})
after(async () => {
await runner.stop()
})
afterEach(function () {
takeScreenshotOnFailure(this.currentTest, webdriver)
})
it('Checklist argument renders multiple checkbox inputs', async function () {
await openChecklistArgumentForm()
const kitchen = await getCheckboxByValueIndex(0)
const bedroom = await getCheckboxByValueIndex(1)
const hallway = await getCheckboxByValueIndex(2)
expect(await kitchen.getAttribute('type')).to.equal('checkbox')
expect(await bedroom.getAttribute('type')).to.equal('checkbox')
expect(await hallway.getAttribute('type')).to.equal('checkbox')
expect(await kitchen.isSelected()).to.be.true
expect(await bedroom.isSelected()).to.be.true
expect(await hallway.isSelected()).to.be.false
})
it('Checklist select none submits an empty value', async function () {
await openChecklistArgumentForm()
const selectNone = await webdriver.findElement(By.xpath("//button[normalize-space()='Select none']"))
await selectNone.click()
await waitForChecklistValue('')
const valueInput = await webdriver.findElement(By.css('.choice-checklist > input'))
expect(await valueInput.getAttribute('value')).to.equal('')
await submitChecklistForm()
await waitForLogsPage()
await waitForExecutionComplete()
await waitForTerminalOutputPattern(/Selected segments:\s*(\r?\n|$)/)
})
it('Checklist select all submits every choice value', async function () {
await openChecklistArgumentForm()
const selectNone = await webdriver.findElement(By.xpath("//button[normalize-space()='Select none']"))
await selectNone.click()
const selectAll = await webdriver.findElement(By.xpath("//button[normalize-space()='Select all']"))
await selectAll.click()
await submitChecklistForm()
await waitForLogsPage()
await waitForExecutionComplete()
await waitForTerminalOutput('["kitchen","bedroom","hallway"]')
})
it('Checklist toggles individual choices before submit', async function () {
await openChecklistArgumentForm()
const hallway = await getCheckboxByValueIndex(2)
await hallway.click()
await submitChecklistForm()
await waitForLogsPage()
await waitForExecutionComplete()
await waitForTerminalOutput('["kitchen","bedroom","hallway"]')
})
it('Checklist entity argument renders choices from entities', async function () {
await openChecklistArgumentForm('Test checklist entity argument')
const checkboxes = await webdriver.findElements(
By.css('.choice-checklist-item input[type="checkbox"]')
)
expect(checkboxes).to.have.length(2)
const labels = await webdriver.findElements(By.css('.choice-checklist-item span'))
expect(await labels[0].getText()).to.equal('attic')
expect(await labels[1].getText()).to.equal('basement')
await checkboxes[0].click()
await submitChecklistForm()
await waitForLogsPage()
await waitForExecutionComplete()
await waitForTerminalOutput('["attic"]', 'Selected rooms')
})
})

View File

@ -0,0 +1,40 @@
---
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
logLevel: "DEBUG"
checkForUpdates: false
defaultPopupOnStart: execution-dialog
entities:
- file: entities/rooms.yaml
name: room
actions:
- title: Test checklist argument
shell: "echo 'Selected segments: {{ segments }}'"
icon: ping
arguments:
- name: segments
title: Rooms to clean
type: checklist
description: Select the rooms to include in the vacuum run.
choices:
- title: Kitchen
value: kitchen
- title: Bedroom
value: bedroom
- title: Hallway
value: hallway
default: kitchen,bedroom
- title: Test checklist entity argument
shell: "echo 'Selected rooms: {{ rooms }}'"
icon: ping
arguments:
- name: rooms
title: Rooms to include
type: checklist
entity: room
choices:
- title: '{{ room.hostname }}'
value: '{{ room.hostname }}'

View File

@ -0,0 +1,2 @@
- hostname: attic
- hostname: basement

View File

@ -20,7 +20,8 @@ message Action {
repeated string exec_on_file_changed_in_dir = 13; repeated string exec_on_file_changed_in_dir = 13;
string exec_on_calendar_file = 14; string exec_on_calendar_file = 14;
repeated ActionWebhookExecHint exec_on_webhooks = 15; repeated ActionWebhookExecHint exec_on_webhooks = 15;
bool justification = 16; reserved 16;
string justification = 20;
bool has_running_instance = 17; bool has_running_instance = 17;
bool has_queued_instance = 18; bool has_queued_instance = 18;
repeated ActionGroupMembership groups = 19; repeated ActionGroupMembership groups = 19;
@ -57,12 +58,19 @@ message ActionArgumentChoice {
string title = 2; string title = 2;
} }
message EntityRelatedAction {
Action action = 1;
map<string, string> prefilled_arguments = 2;
}
message Entity { message Entity {
string title = 1; string title = 1;
string unique_key = 2; string unique_key = 2;
string type = 3; string type = 3;
repeated string directories = 4; repeated string directories = 4;
map<string, string> fields = 5; map<string, string> fields = 5;
repeated EntityRelatedAction related_actions = 6;
string icon = 7;
} }
message GetDashboardResponse { message GetDashboardResponse {
@ -427,6 +435,10 @@ message GetActionBindingResponse {
} }
message GetEntitiesRequest { message GetEntitiesRequest {
string entity_type = 1;
string filter = 2;
int32 page = 3;
int32 page_size = 4;
} }
message GetEntitiesResponse { message GetEntitiesResponse {
@ -437,6 +449,14 @@ message EntityDefinition {
string title = 1; string title = 1;
repeated Entity instances = 2; repeated Entity instances = 2;
repeated string used_on_dashboards = 3; repeated string used_on_dashboards = 3;
string icon = 4;
repeated EntityProperty properties = 5;
int32 total_instances = 6;
}
message EntityProperty {
string name = 1;
string title = 2;
} }
message GetEntityRequest { message GetEntityRequest {

289
scripts/devcheck.py Executable file
View File

@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""Scan project Makefiles for development tools and report PATH availability."""
from __future__ import annotations
import os
import re
import shutil
import sys
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FAIL_GROUPS = frozenset({
"Core build and test",
"Go codestyle (install via: make go-tools)",
"Protocol buffers (install via: make -C service go-tools-all)",
})
SKIP_DIRS = frozenset({
".git",
"node_modules",
"vendor",
"dist",
"reports",
"webui",
})
SKIP_COMMANDS = frozenset({
".", ":", "[", "bash", "case", "cat", "cd", "chmod", "chown", "cp", "curl",
"do", "done", "echo", "elif", "else", "esac", "exit", "false", "fi", "for",
"fuser", "grep", "head", "if", "kill", "killall", "lsof", "make", "mkdir",
"mv", "objdump", "pwd", "rm", "sed", "set", "sh", "sleep", "tail", "test",
"then", "touch", "trap", "true", "unzip", "while",
})
SKIP_PREFIXES = ("./", "../", "-", "$(")
GO_INSTALL_RE = re.compile(r"""go\s+install\s+(?:["'])([^"']+)(?:["'])""")
TOOL_GROUPS: list[tuple[str, frozenset[str]]] = [
(
"Core build and test",
frozenset({"go", "npm", "npx", "node", "python3"}),
),
(
"Go codestyle (install via: make go-tools)",
frozenset({"gocyclo", "gocritic"}),
),
(
"Protocol buffers (install via: make -C service go-tools-all)",
frozenset({"buf", "protoc-gen-go"}),
),
(
"Containers and packaging (optional)",
frozenset({"buildah", "docker", "podman", "podman-compose"}),
),
]
class Color:
RESET = "\033[0m"
BOLD = "\033[1m"
GREEN = "\033[32m"
RED = "\033[31m"
ORANGE = "\033[38;5;208m"
DIM = "\033[2m"
@classmethod
def ok(cls, text: str) -> str:
return f"{cls.BOLD}{cls.GREEN}{text}{cls.RESET}"
@classmethod
def fail(cls, text: str) -> str:
return f"{cls.BOLD}{cls.RED}{text}{cls.RESET}"
@classmethod
def warn(cls, text: str) -> str:
return f"{cls.BOLD}{cls.ORANGE}{text}{cls.RESET}"
@classmethod
def dim(cls, text: str) -> str:
return f"{cls.DIM}{text}{cls.RESET}"
def colors_enabled() -> bool:
if os.environ.get("NO_COLOR"):
return False
if "--no-color" in sys.argv:
return False
return sys.stdout.isatty()
def paint_ok(text: str) -> str:
return Color.ok(text) if colors_enabled() else text
def paint_fail(text: str) -> str:
return Color.fail(text) if colors_enabled() else text
def paint_warn(text: str) -> str:
return Color.warn(text) if colors_enabled() else text
def paint_dim(text: str) -> str:
return Color.dim(text) if colors_enabled() else text
def find_makefiles(root: Path) -> list[Path]:
makefiles: list[Path] = []
for path in root.rglob("Makefile"):
rel_parts = path.relative_to(root).parts
if any(part.startswith(".") or part in SKIP_DIRS for part in rel_parts):
continue
makefiles.append(path)
return sorted(makefiles)
def tool_from_go_install(package: str) -> str:
return package.rstrip("/").rsplit("/", 1)[-1]
def first_command_token(line: str) -> str | None:
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith("$(call") or line.startswith("$(MAKE)"):
return None
for token in line.split():
if "=" in token and not token.startswith("./"):
continue
if token.startswith(SKIP_PREFIXES):
return None
return token.strip("\"'")
return None
def collect_tools(makefiles: list[Path]) -> dict[str, set[str]]:
tools: dict[str, set[str]] = defaultdict(set)
for makefile in makefiles:
rel = makefile.relative_to(ROOT).as_posix()
try:
lines = makefile.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
continue
for line in lines:
if not line.startswith("\t"):
continue
recipe = line.lstrip("\t@")
for package in GO_INSTALL_RE.findall(recipe):
tools[tool_from_go_install(package)].add(rel)
if "python -c" in recipe or recipe.startswith("python "):
tools["python3"].add(rel)
command = first_command_token(recipe)
if command is None:
continue
command = command.lower()
if command in SKIP_COMMANDS or command == "python":
continue
if "/" in command and command not in {"podman-compose"}:
continue
tools[command].add(rel)
return dict(tools)
def resolve_python() -> tuple[bool, str | None]:
for name in ("python3", "python"):
path = shutil.which(name)
if path is not None:
return True, path
return False, None
def resolve_tool(name: str) -> tuple[bool, str | None]:
if name == "python3":
return resolve_python()
path = shutil.which(name)
return path is not None, path
def group_tools(tools: dict[str, set[str]]) -> list[tuple[str, list[str]]]:
grouped: list[tuple[str, list[str]]] = []
assigned: set[str] = set()
for title, members in TOOL_GROUPS:
present = sorted(tool for tool in tools if tool in members)
if present:
grouped.append((title, present))
assigned.update(present)
remaining = sorted(tool for tool in tools if tool not in assigned)
if remaining:
grouped.append(("Other tools found in Makefiles", remaining))
return grouped
def format_status(found: bool, path: str | None, *, required: bool) -> str:
if found:
return paint_ok(f"OK {path}")
if required:
return paint_fail("MISSING")
return paint_warn("MISSING")
def print_group(
title: str,
tool_names: list[str],
tools: dict[str, set[str]],
verbose: bool,
) -> tuple[int, int, list[str]]:
required = title in FAIL_GROUPS
print(title)
available = 0
missing: list[str] = []
for name in tool_names:
found, path = resolve_tool(name)
available += int(found)
if not found:
missing.append(name)
print(f" {name:<16} {format_status(found, path, required=required)}")
if verbose:
for source in sorted(tools[name]):
print(paint_dim(f" referenced in {source}"))
print()
return available, len(tool_names), missing
def main() -> int:
verbose = "--verbose" in sys.argv or "-v" in sys.argv
makefiles = find_makefiles(ROOT)
tools = collect_tools(makefiles)
print("Development environment check")
print(f"Scanned {len(makefiles)} Makefile(s) under {ROOT}")
print()
total_available = 0
total_checked = 0
required_missing: list[str] = []
optional_missing: list[str] = []
for title, tool_names in group_tools(tools):
available, checked, missing = print_group(title, tool_names, tools, verbose)
total_available += available
total_checked += checked
if title in FAIL_GROUPS:
required_missing.extend(missing)
else:
optional_missing.extend(missing)
summary = f"Summary: {total_available}/{total_checked} tools available on PATH"
if required_missing:
print(paint_fail(summary))
elif optional_missing:
print(paint_warn(summary))
else:
print(paint_ok(summary))
if required_missing:
print()
print(paint_fail("Missing required tools: " + ", ".join(required_missing)))
print(paint_dim("Install the missing tools for your platform, then re-run: make devcheck"))
return 1
if optional_missing:
print()
print(paint_warn("Missing optional tools: " + ", ".join(optional_missing)))
return 0
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because it is too large Load Diff

View File

@ -4,9 +4,14 @@ import (
ctx "context" ctx "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http"
"os" "os"
"path" "path"
"sort" "sort"
"strings"
"sync"
"time"
"connectrpc.com/connect" "connectrpc.com/connect"
"google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/encoding/protojson"
@ -16,11 +21,6 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"fmt"
"net/http"
"sync"
"time"
acl "github.com/OliveTin/OliveTin/internal/acl" acl "github.com/OliveTin/OliveTin/internal/acl"
auth "github.com/OliveTin/OliveTin/internal/auth" auth "github.com/OliveTin/OliveTin/internal/auth"
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
@ -156,15 +156,17 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.
} }
authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg) authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
if err := validateJustificationRequired(pair.Action, req.Msg.Justification, authenticatedUser); err != nil { args := startActionArgumentsFromProto(req.Msg.Arguments)
justification := resolveStartJustification(pair.Action, pair, req.Msg.Justification, args)
if err := validateJustificationRequired(pair.Action, justification, authenticatedUser); err != nil {
return nil, connectInvalidJustification(err) return nil, connectInvalidJustification(err)
} }
execReq := executor.ExecutionRequest{ execReq := executor.ExecutionRequest{
Binding: pair, Binding: pair,
TrackingID: req.Msg.UniqueTrackingId, TrackingID: req.Msg.UniqueTrackingId,
Arguments: startActionArgumentsFromProto(req.Msg.Arguments), Arguments: args,
Justification: req.Msg.Justification, Justification: justification,
AuthenticatedUser: authenticatedUser, AuthenticatedUser: authenticatedUser,
Cfg: api.cfg, Cfg: api.cfg,
} }
@ -280,6 +282,21 @@ func (api *oliveTinAPI) findBindingByIDOrNotFound(bindingId string) (*executor.A
return api.findBindingOrNotFound(bindingId) return api.findBindingOrNotFound(bindingId)
} }
func (api *oliveTinAPI) startActionAndWaitLogEntry(binding *executor.ActionBinding, args map[string]string, justification string, user *authpublic.AuthenticatedUser) (*apiv1.LogEntry, error) {
internalLogEntry, ok := api.startActionAndWaitRun(binding, args, justification, user)
if !ok {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
}
return api.logEntryForAllowedViewer(internalLogEntry, user)
}
func (api *oliveTinAPI) logEntryForAllowedViewer(internalLogEntry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) (*apiv1.LogEntry, error) {
if err := api.requireLogEntryAllowed(internalLogEntry, user); err != nil {
return nil, err
}
return api.internalLogEntryToPb(internalLogEntry, user), nil
}
func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) { func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) {
binding, err := api.findBindingOrNotFound(req.Msg.ActionId) binding, err := api.findBindingOrNotFound(req.Msg.ActionId)
if err != nil { if err != nil {
@ -287,16 +304,18 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request
} }
user := auth.UserFromApiCall(ctx, req, api.cfg) user := auth.UserFromApiCall(ctx, req, api.cfg)
if err := validateJustificationRequired(binding.Action, req.Msg.Justification, user); err != nil { args := startActionArgumentsFromProto(req.Msg.Arguments)
justification := resolveStartJustification(binding.Action, binding, req.Msg.Justification, args)
if err := validateJustificationRequired(binding.Action, justification, user); err != nil {
return nil, connectInvalidJustification(err) return nil, connectInvalidJustification(err)
} }
internalLogEntry, ok := api.startActionAndWaitRun(binding, startActionArgumentsFromProto(req.Msg.Arguments), req.Msg.Justification, user) logEntry, err := api.startActionAndWaitLogEntry(binding, args, justification, user)
if !ok { if err != nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found")) return nil, err
} }
return connect.NewResponse(&apiv1.StartActionAndWaitResponse{ return connect.NewResponse(&apiv1.StartActionAndWaitResponse{
LogEntry: api.internalLogEntryToPb(internalLogEntry, user), LogEntry: logEntry,
}), nil }), nil
} }
@ -323,16 +342,7 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[a
}), nil }), nil
} }
func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) { func (api *oliveTinAPI) runBindingAndWait(binding *executor.ActionBinding, args map[string]string, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, bool) {
binding := api.executor.FindBindingByID(req.Msg.ActionId)
if binding == nil || binding.Action == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
}
args := make(map[string]string)
user := auth.UserFromApiCall(ctx, req, api.cfg)
execReq := executor.ExecutionRequest{ execReq := executor.ExecutionRequest{
Binding: binding, Binding: binding,
TrackingID: uuid.NewString(), TrackingID: uuid.NewString(),
@ -344,14 +354,31 @@ func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Re
wg, _ := api.executor.ExecRequest(&execReq) wg, _ := api.executor.ExecRequest(&execReq)
wg.Wait() wg.Wait()
internalLogEntry, ok := api.executor.GetLog(execReq.TrackingID) return api.executor.GetLog(execReq.TrackingID)
}
if ok { func (api *oliveTinAPI) startActionByGetAndWaitLogEntry(binding *executor.ActionBinding, user *authpublic.AuthenticatedUser) (*apiv1.LogEntry, error) {
return connect.NewResponse(&apiv1.StartActionByGetAndWaitResponse{ internalLogEntry, ok := api.runBindingAndWait(binding, map[string]string{}, user)
LogEntry: api.internalLogEntryToPb(internalLogEntry, user), if !ok {
}), nil return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
} }
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found")) return api.logEntryForAllowedViewer(internalLogEntry, user)
}
func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
binding := api.executor.FindBindingByID(req.Msg.ActionId)
if binding == nil || binding.Action == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
}
user := auth.UserFromApiCall(ctx, req, api.cfg)
logEntry, err := api.startActionByGetAndWaitLogEntry(binding, user)
if err != nil {
return nil, err
}
return connect.NewResponse(&apiv1.StartActionByGetAndWaitResponse{
LogEntry: logEntry,
}), nil
} }
func calculateRateLimitExpires(api *oliveTinAPI, logEntry *executor.InternalLogEntry) string { func calculateRateLimitExpires(api *oliveTinAPI, logEntry *executor.InternalLogEntry) string {
@ -680,6 +707,13 @@ func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *au
return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action) return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action)
} }
func (api *oliveTinAPI) requireLogEntryAllowed(entry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) error {
if api.isLogEntryAllowed(entry, user) {
return nil
}
return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied to view this execution"))
}
// mayViewExecutionEvent returns whether the user is allowed to receive this execution event (for EventStream ACL). // mayViewExecutionEvent returns whether the user is allowed to receive this execution event (for EventStream ACL).
func (api *oliveTinAPI) mayViewExecutionEvent(entry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool { func (api *oliveTinAPI) mayViewExecutionEvent(entry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool {
if user == nil { if user == nil {
@ -1373,21 +1407,7 @@ func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.
} }
entityMap := entities.GetEntities() entityMap := entities.GetEntities()
entityNames := make([]string, 0, len(entityMap)) entityDefinitions := api.buildEntityDefinitionsResponse(req.Msg, entityMap)
for name := range entityMap {
entityNames = append(entityNames, name)
}
sort.Strings(entityNames)
entityDefinitions := make([]*apiv1.EntityDefinition, 0, len(entityNames))
for _, name := range entityNames {
def := &apiv1.EntityDefinition{
Title: name,
UsedOnDashboards: findDashboardsForEntity(name, api.cfg.Dashboards),
Instances: buildSortedEntityInstances(name, entityMap[name]),
}
entityDefinitions = append(entityDefinitions, def)
}
res := &apiv1.GetEntitiesResponse{ res := &apiv1.GetEntitiesResponse{
EntityDefinitions: entityDefinitions, EntityDefinitions: entityDefinitions,
@ -1396,7 +1416,7 @@ func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.
return connect.NewResponse(res), nil return connect.NewResponse(res), nil
} }
func buildSortedEntityInstances(entityType string, entityInstances map[string]*entities.Entity) []*apiv1.Entity { func buildSortedEntityInstances(entityType string, entityInstances map[string]*entities.Entity, properties []config.EntityProperty) []*apiv1.Entity {
instanceKeys := make([]string, 0, len(entityInstances)) instanceKeys := make([]string, 0, len(entityInstances))
for key := range entityInstances { for key := range entityInstances {
instanceKeys = append(instanceKeys, key) instanceKeys = append(instanceKeys, key)
@ -1410,6 +1430,7 @@ func buildSortedEntityInstances(entityType string, entityInstances map[string]*e
Title: e.Title, Title: e.Title,
UniqueKey: e.UniqueKey, UniqueKey: e.UniqueKey,
Type: entityType, Type: entityType,
Fields: entityListFields(e.Data, properties),
}) })
} }
return instances return instances
@ -1511,17 +1532,100 @@ func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.Ge
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type)) return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type))
} }
res := buildEntityResponse(entity, req.Msg.Type, api.cfg.Dashboards) res := buildEntityResponse(entity, req.Msg.Type, api.cfg)
res.RelatedActions = api.relatedActionsForEntity(user, req.Msg.Type, entity)
return connect.NewResponse(res), nil return connect.NewResponse(res), nil
} }
func buildEntityResponse(entity *entities.Entity, entityType string, dashboards []*config.DashboardComponent) *apiv1.Entity { func entityTypeIcon(cfg *config.Config, entityType string) string {
entityFile := entityFileForType(cfg, entityType)
if entityFile == nil {
return ""
}
return entityFile.Icon
}
func entityFileForType(cfg *config.Config, entityType string) *config.EntityFile {
for _, entityFile := range cfg.Entities {
if entityFile != nil && entityFile.Name == entityType {
return entityFile
}
}
return nil
}
func entityPropertiesFromFile(entityFile *config.EntityFile) []config.EntityProperty {
if entityFile == nil {
return nil
}
return entityFile.Properties
}
func entityDefinitionProperties(properties []config.EntityProperty) []*apiv1.EntityProperty {
if len(properties) == 0 {
return nil
}
result := make([]*apiv1.EntityProperty, 0, len(properties))
for _, property := range properties {
result = append(result, &apiv1.EntityProperty{
Name: property.Name,
Title: property.Title,
})
}
return result
}
func entityListFields(data any, properties []config.EntityProperty) map[string]string {
if len(properties) == 0 {
return nil
}
fields := make(map[string]string, len(properties))
for _, property := range properties {
fields[property.Name] = entityPropertyValue(data, property.Name)
}
return fields
}
func entityPropertyValue(data any, propertyName string) string {
dataMap, ok := data.(map[string]any)
if !ok {
return ""
}
if value, found := dataMap[propertyName]; found {
return fmt.Sprintf("%v", value)
}
return entityPropertyValueCaseInsensitive(dataMap, propertyName)
}
func entityPropertyValueCaseInsensitive(dataMap map[string]any, propertyName string) string {
propertyNameLower := strings.ToLower(propertyName)
for key, value := range dataMap {
if strings.ToLower(key) == propertyNameLower {
return fmt.Sprintf("%v", value)
}
}
return ""
}
func buildEntityResponse(entity *entities.Entity, entityType string, cfg *config.Config) *apiv1.Entity {
properties := entityPropertiesFromFile(entityFileForType(cfg, entityType))
res := &apiv1.Entity{ res := &apiv1.Entity{
Title: entity.Title, Title: entity.Title,
UniqueKey: entity.UniqueKey, UniqueKey: entity.UniqueKey,
Type: entityType, Type: entityType,
Directories: findDirectoriesInEntityFieldsets(entityType, dashboards), Directories: findDirectoriesInEntityFieldsets(entityType, cfg.Dashboards),
Fields: serializeEntityFields(entity.Data), Fields: entityFieldsForResponse(entity.Data, properties),
Icon: entityTypeIcon(cfg, entityType),
} }
return res return res
} }

View File

@ -0,0 +1,154 @@
package api
import (
"sort"
"strings"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities"
)
const (
defaultEntityInstancesPageSize = 10
maxEntityInstancesPageSize = 100
)
func (api *oliveTinAPI) buildEntityDefinitionsResponse(req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
if req != nil && req.EntityType != "" {
return api.buildFilteredEntityDefinitions(req, entityMap)
}
return api.buildAllEntityDefinitions(entityMap)
}
func (api *oliveTinAPI) buildAllEntityDefinitions(entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
entityNames := sortedEntityTypeNames(entityMap)
entityDefinitions := make([]*apiv1.EntityDefinition, 0, len(entityNames))
for _, name := range entityNames {
entityFile := entityFileForType(api.cfg, name)
properties := entityPropertiesFromFile(entityFile)
instances := buildSortedEntityInstances(name, entityMap[name], properties)
def := &apiv1.EntityDefinition{
Title: name,
UsedOnDashboards: findDashboardsForEntity(name, api.cfg.Dashboards),
Icon: entityTypeIcon(api.cfg, name),
Properties: entityDefinitionProperties(properties),
TotalInstances: int32(len(instances)),
}
if len(properties) == 0 {
def.Instances = instances
}
entityDefinitions = append(entityDefinitions, def)
}
return entityDefinitions
}
func (api *oliveTinAPI) buildFilteredEntityDefinitions(req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
entityInstances, ok := entityMap[req.EntityType]
if !ok || len(entityInstances) == 0 {
return nil
}
entityFile := entityFileForType(api.cfg, req.EntityType)
properties := entityPropertiesFromFile(entityFile)
instances := buildSortedEntityInstances(req.EntityType, entityInstances, properties)
filtered := filterEntityInstances(instances, req.Filter)
pageSize := normalizeEntityInstancesPageSize(req.PageSize)
page := normalizeEntityInstancesPage(req.Page)
def := &apiv1.EntityDefinition{
Title: req.EntityType,
UsedOnDashboards: findDashboardsForEntity(req.EntityType, api.cfg.Dashboards),
Icon: entityTypeIcon(api.cfg, req.EntityType),
Properties: entityDefinitionProperties(properties),
TotalInstances: int32(len(filtered)),
Instances: paginateEntityInstances(filtered, page, pageSize),
}
return []*apiv1.EntityDefinition{def}
}
func sortedEntityTypeNames(entityMap entities.EntitiesByClass) []string {
entityNames := make([]string, 0, len(entityMap))
for name := range entityMap {
entityNames = append(entityNames, name)
}
sort.Strings(entityNames)
return entityNames
}
func normalizeEntityInstancesPage(page int32) int32 {
if page < 1 {
return 1
}
return page
}
func normalizeEntityInstancesPageSize(pageSize int32) int32 {
if pageSize < 1 {
return defaultEntityInstancesPageSize
}
if pageSize > maxEntityInstancesPageSize {
return maxEntityInstancesPageSize
}
return pageSize
}
func filterEntityInstances(instances []*apiv1.Entity, filter string) []*apiv1.Entity {
filter = strings.TrimSpace(strings.ToLower(filter))
if filter == "" {
return instances
}
filtered := make([]*apiv1.Entity, 0, len(instances))
for _, instance := range instances {
if entityInstanceMatchesFilter(instance, filter) {
filtered = append(filtered, instance)
}
}
return filtered
}
func entityInstanceMatchesFilter(instance *apiv1.Entity, filter string) bool {
if strings.Contains(strings.ToLower(instance.Title), filter) {
return true
}
for _, value := range instance.Fields {
if strings.Contains(strings.ToLower(value), filter) {
return true
}
}
return false
}
func paginateEntityInstances(instances []*apiv1.Entity, page, pageSize int32) []*apiv1.Entity {
count := int64(len(instances))
start := int64(page-1) * int64(pageSize)
if start >= count {
return []*apiv1.Entity{}
}
end := start + int64(pageSize)
if end > count {
end = count
}
return instances[int(start):int(end)]
}
func entityFieldsForResponse(data any, properties []config.EntityProperty) map[string]string {
if len(properties) > 0 {
return entityListFields(data, properties)
}
return serializeEntityFields(data)
}

View File

@ -0,0 +1,162 @@
package api
import (
"context"
"testing"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities"
"github.com/OliveTin/OliveTin/internal/executor"
)
func TestGetEntitiesPaginatesAndFiltersInstances(t *testing.T) {
entities.ClearEntitiesOfType("server")
entities.AddEntity("server", "0", map[string]any{"name": "alpha", "hostname": "alpha.example.com", "ip": "10.0.0.1"})
entities.AddEntity("server", "1", map[string]any{"name": "beta", "hostname": "beta.example.com", "ip": "10.0.0.2"})
entities.AddEntity("server", "2", map[string]any{"name": "gamma", "hostname": "gamma.example.com", "ip": "10.0.0.3"})
t.Cleanup(func() {
entities.ClearEntitiesOfType("server")
})
cfg := config.DefaultConfig()
cfg.Entities = []*config.EntityFile{
{
Name: "server",
Properties: []config.EntityProperty{
{Name: "hostname", Title: "Hostname"},
{Name: "ip", Title: "IP"},
},
},
}
cfg.Sanitize()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
filteredResp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
EntityType: "server",
Filter: "beta",
Page: 1,
PageSize: 10,
}))
require.NoError(t, err)
require.Len(t, filteredResp.Msg.EntityDefinitions, 1)
assert.Equal(t, int32(1), filteredResp.Msg.EntityDefinitions[0].TotalInstances)
require.Len(t, filteredResp.Msg.EntityDefinitions[0].Instances, 1)
assert.Equal(t, "beta.example.com", filteredResp.Msg.EntityDefinitions[0].Instances[0].Fields["hostname"])
pagedResp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
EntityType: "server",
Page: 2,
PageSize: 1,
}))
require.NoError(t, err)
require.Len(t, pagedResp.Msg.EntityDefinitions, 1)
assert.Equal(t, int32(3), pagedResp.Msg.EntityDefinitions[0].TotalInstances)
require.Len(t, pagedResp.Msg.EntityDefinitions[0].Instances, 1)
assert.Equal(t, "1", pagedResp.Msg.EntityDefinitions[0].Instances[0].UniqueKey)
}
func TestGetEntitiesUnfilteredIncludesConfiguredProperties(t *testing.T) {
entities.ClearEntitiesOfType("server")
entities.AddEntity("server", "0", map[string]any{"name": "alpha", "hostname": "alpha.example.com", "ip": "10.0.0.1"})
t.Cleanup(func() {
entities.ClearEntitiesOfType("server")
})
cfg := config.DefaultConfig()
cfg.Entities = []*config.EntityFile{
{
Name: "server",
Properties: []config.EntityProperty{
{Name: "hostname", Title: "Hostname"},
},
},
}
cfg.Sanitize()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{}))
require.NoError(t, err)
serverDef := findEntityDefinition(resp.Msg.EntityDefinitions, "server")
require.NotNil(t, serverDef)
require.Len(t, serverDef.Properties, 1)
assert.Equal(t, "hostname", serverDef.Properties[0].Name)
assert.Equal(t, int32(1), serverDef.TotalInstances)
assert.Empty(t, serverDef.Instances)
}
func TestPaginateEntityInstancesHandlesLargePageValues(t *testing.T) {
instances := []*apiv1.Entity{
{UniqueKey: "0"},
{UniqueKey: "1"},
}
assert.Empty(t, paginateEntityInstances(instances, 1<<30, 1))
assert.Empty(t, paginateEntityInstances(instances, 2, 1<<30))
assert.Equal(t, []*apiv1.Entity{{UniqueKey: "1"}}, paginateEntityInstances(instances, 2, 1))
}
func findEntityDefinition(definitions []*apiv1.EntityDefinition, title string) *apiv1.EntityDefinition {
for _, definition := range definitions {
if definition.Title == title {
return definition
}
}
return nil
}
func TestGetEntityRestrictsFieldsToConfiguredProperties(t *testing.T) {
entities.ClearEntitiesOfType("server")
entities.AddEntity("server", "0", map[string]any{
"name": "alpha",
"hostname": "alpha.example.com",
"ip": "10.0.0.1",
"groups": []string{"admins"},
})
t.Cleanup(func() {
entities.ClearEntitiesOfType("server")
})
cfg := config.DefaultConfig()
cfg.Entities = []*config.EntityFile{
{
Name: "server",
Properties: []config.EntityProperty{
{Name: "hostname", Title: "Hostname"},
{Name: "ip", Title: "IP"},
},
},
}
cfg.Sanitize()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
resp, err := client.GetEntity(context.Background(), connect.NewRequest(&apiv1.GetEntityRequest{
Type: "server",
UniqueKey: "0",
}))
require.NoError(t, err)
require.NotNil(t, resp.Msg)
assert.Equal(t, "alpha.example.com", resp.Msg.Fields["hostname"])
assert.Equal(t, "10.0.0.1", resp.Msg.Fields["ip"])
assert.NotContains(t, resp.Msg.Fields, "groups")
assert.NotContains(t, resp.Msg.Fields, "name")
}

View File

@ -0,0 +1,130 @@
package api
import (
"sort"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities"
"github.com/OliveTin/OliveTin/internal/executor"
"github.com/OliveTin/OliveTin/internal/tpl"
)
type relatedActionCandidate struct {
binding *executor.ActionBinding
prefilled map[string]string
}
func (api *oliveTinAPI) relatedActionsForEntity(user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity) []*apiv1.EntityRelatedAction {
renderRequest := api.createDashboardRenderRequest(user, entityType, entity.UniqueKey)
populateActiveBindingStates(renderRequest)
candidates := collectRelatedActionCandidates(api, user, entityType, entity)
sortRelatedActionCandidates(candidates)
return buildEntityRelatedActions(candidates, renderRequest)
}
func collectRelatedActionCandidates(api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity) []relatedActionCandidate {
seen := make(map[string]bool)
candidates := make([]relatedActionCandidate, 0)
api.executor.MapActionBindingsLock.RLock()
defer api.executor.MapActionBindingsLock.RUnlock()
for _, binding := range api.executor.MapActionBindings {
tryAppendRelatedCandidate(&candidates, seen, api, user, entityType, entity, binding)
}
return candidates
}
func tryAppendRelatedCandidate(candidates *[]relatedActionCandidate, seen map[string]bool, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity, binding *executor.ActionBinding) {
prefilled, ok := relatedPrefillForBinding(binding, entityType, entity)
if !ok || !bindingViewableForRelated(seen, api, user, binding) {
return
}
seen[binding.ID] = true
*candidates = append(*candidates, relatedActionCandidate{
binding: binding,
prefilled: prefilled,
})
}
func bindingViewableForRelated(seen map[string]bool, api *oliveTinAPI, user *authpublic.AuthenticatedUser, binding *executor.ActionBinding) bool {
return binding != nil && binding.Action != nil && !seen[binding.ID] && api.userCanViewAction(user, binding.Action)
}
func relatedPrefillForBinding(binding *executor.ActionBinding, entityType string, entity *entities.Entity) (map[string]string, bool) {
if isEntityBoundBindingFor(binding, entityType, entity) {
return nil, true
}
return argumentEntityPrefill(binding, entityType, entity)
}
func argumentEntityPrefill(binding *executor.ActionBinding, entityType string, entity *entities.Entity) (map[string]string, bool) {
if binding == nil || binding.Entity != nil || binding.Action == nil {
return nil, false
}
prefilled := buildPrefilledArgumentsForEntity(binding.Action, entityType, entity)
return prefilled, len(prefilled) > 0
}
func isEntityBoundBindingFor(binding *executor.ActionBinding, entityType string, entity *entities.Entity) bool {
if entity == nil || !bindingHasEntity(binding) {
return false
}
return binding.Action.Entity == entityType && binding.Entity.UniqueKey == entity.UniqueKey
}
func bindingHasEntity(binding *executor.ActionBinding) bool {
return binding != nil && binding.Entity != nil && binding.Action != nil
}
func buildPrefilledArgumentsForEntity(action *config.Action, entityType string, entity *entities.Entity) map[string]string {
prefilled := make(map[string]string)
for i := range action.Arguments {
arg := &action.Arguments[i]
if arg.Entity != entityType || len(arg.Choices) != 1 {
continue
}
prefilled[arg.Name] = tpl.ParseTemplateOfActionBeforeExec(arg.Choices[0].Value, entity)
}
return prefilled
}
func sortRelatedActionCandidates(candidates []relatedActionCandidate) {
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].binding.ConfigOrder != candidates[j].binding.ConfigOrder {
return candidates[i].binding.ConfigOrder < candidates[j].binding.ConfigOrder
}
return candidates[i].binding.ID < candidates[j].binding.ID
})
}
func buildEntityRelatedActions(candidates []relatedActionCandidate, rr *DashboardRenderRequest) []*apiv1.EntityRelatedAction {
result := make([]*apiv1.EntityRelatedAction, 0, len(candidates))
for _, candidate := range candidates {
action := buildAction(candidate.binding, rr)
if action == nil {
continue
}
result = append(result, &apiv1.EntityRelatedAction{
Action: action,
PrefilledArguments: candidate.prefilled,
})
}
return result
}

View File

@ -0,0 +1,270 @@
package api
import (
"context"
"testing"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities"
"github.com/OliveTin/OliveTin/internal/executor"
)
func setupHostEntityTestData(t *testing.T) {
t.Helper()
entities.ClearEntitiesOfType("host")
entities.AddEntity("host", "0", map[string]any{"name": "stuffbox", "hostname": "192.168.66.8"})
entities.AddEntity("host", "1", map[string]any{"name": "lurker", "hostname": "192.168.66.1"})
t.Cleanup(func() {
entities.ClearEntitiesOfType("host")
})
}
func buildRelatedActionsTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser, *authpublic.AuthenticatedUser) {
t.Helper()
cfg := config.DefaultConfig()
cfg.DefaultPermissions.View = false
cfg.DefaultPermissions.Exec = false
cfg.Actions = append(cfg.Actions,
&config.Action{
Title: "Secret Entity Action",
Shell: "echo secret",
Entity: "host",
},
&config.Action{
Title: "Hidden Host Action",
Shell: "echo hidden",
Entity: "host",
Hidden: true,
},
&config.Action{
ID: "run_playbook",
Title: "Run Automation Playbook",
Shell: "host '{{ ansible_host }}'",
Arguments: []config.ActionArgument{
{
Name: "ansible_host",
Title: "Host",
Entity: "host",
Choices: []config.ActionArgumentChoice{
{Title: "{{ host.name }} ({{ host.hostname }})", Value: "{{ host.hostname }}"},
},
},
},
},
&config.Action{
Title: "Public Host Action",
Shell: "echo public",
Entity: "host",
},
)
cfg.AccessControlLists = append(cfg.AccessControlLists,
&config.AccessControlList{
Name: "restricted",
MatchUsernames: []string{"low"},
AddToEveryAction: true,
Permissions: config.PermissionsList{View: false, Exec: false, Logs: false, Kill: false},
},
&config.AccessControlList{
Name: "full",
MatchUsernames: []string{"admin"},
AddToEveryAction: true,
Permissions: config.PermissionsList{View: true, Exec: true, Logs: true, Kill: true},
},
)
cfg.Entities = []*config.EntityFile{
{File: "hosts.yaml", Name: "host", Icon: "ssh"},
}
cfg.Sanitize()
lowUser := &authpublic.AuthenticatedUser{Username: "low", Acls: []string{"restricted"}}
adminUser := &authpublic.AuthenticatedUser{Username: "admin", Acls: []string{"full"}}
return cfg, lowUser, adminUser
}
func getEntityRelatedActionTitles(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string {
t.Helper()
entity, ok := entities.GetEntityInstances(entityType)[entityKey]
require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
related := api.relatedActionsForEntity(user, entityType, entity)
titles := make([]string, 0, len(related))
for _, item := range related {
if item.Action != nil {
titles = append(titles, item.Action.Title)
}
}
return titles
}
func getEntityRelatedBindingIDs(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string {
t.Helper()
entity, ok := entities.GetEntityInstances(entityType)[entityKey]
require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
related := api.relatedActionsForEntity(user, entityType, entity)
ids := make([]string, 0, len(related))
for _, item := range related {
if item.Action != nil {
ids = append(ids, item.Action.BindingId)
}
}
return ids
}
func TestGetEntityRelatedActionsDeniesRestrictedView(t *testing.T) {
setupHostEntityTestData(t)
cfg, lowUser, _ := buildRelatedActionsTestConfig(t)
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
api := newServer(ex)
ids := getEntityRelatedBindingIDs(t, api, lowUser, "host", "0")
assert.Empty(t, ids)
titles := getEntityRelatedActionTitles(t, api, lowUser, "host", "0")
assert.Empty(t, titles)
}
func TestGetEntityRelatedActionsAllowsAdminView(t *testing.T) {
setupHostEntityTestData(t)
cfg, _, adminUser := buildRelatedActionsTestConfig(t)
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
api := newServer(ex)
titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0")
assert.Contains(t, titles, "Run Automation Playbook")
assert.Contains(t, titles, "Public Host Action")
assert.Contains(t, titles, "Secret Entity Action")
}
func TestGetEntityRelatedActionsExcludesHiddenActions(t *testing.T) {
setupHostEntityTestData(t)
cfg, _, adminUser := buildRelatedActionsTestConfig(t)
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
api := newServer(ex)
titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0")
assert.NotContains(t, titles, "Hidden Host Action")
}
func TestGetEntityRelatedActionsEntityBoundMatchesInstanceOnly(t *testing.T) {
setupHostEntityTestData(t)
cfg := config.DefaultConfig()
cfg.Actions = append(cfg.Actions, &config.Action{
Title: "{{ host.name }} Wake",
Shell: "echo wake",
Entity: "host",
})
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
api := newServer(ex)
user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
titlesHost0 := getEntityRelatedActionTitles(t, api, user, "host", "0")
assert.Len(t, titlesHost0, 1)
assert.Equal(t, "stuffbox Wake", titlesHost0[0])
titlesHost1 := getEntityRelatedActionTitles(t, api, user, "host", "1")
assert.Len(t, titlesHost1, 1)
assert.Equal(t, "lurker Wake", titlesHost1[0])
}
func TestGetEntityRelatedActionsPrefillsArgumentEntityValues(t *testing.T) {
setupHostEntityTestData(t)
cfg := config.DefaultConfig()
cfg.Actions = append(cfg.Actions, &config.Action{
ID: "run_playbook",
Title: "Run Automation Playbook",
Shell: "host '{{ ansible_host }}'",
Arguments: []config.ActionArgument{
{
Name: "ansible_host",
Title: "Host",
Entity: "host",
Choices: []config.ActionArgumentChoice{
{Title: "{{ host.name }} ({{ host.hostname }})", Value: "{{ host.hostname }}"},
},
},
},
})
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
api := newServer(ex)
user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
entity := entities.GetEntityInstances("host")["0"]
related := api.relatedActionsForEntity(user, "host", entity)
require.Len(t, related, 1)
require.NotNil(t, related[0].Action)
assert.Equal(t, "run_playbook", related[0].Action.BindingId)
assert.Equal(t, "192.168.66.8", related[0].PrefilledArguments["ansible_host"])
}
func TestGetEntityDeniesGuestsWhenLoginRequired(t *testing.T) {
setupHostEntityTestData(t)
cfg := config.DefaultConfig()
cfg.AuthRequireGuestsToLogin = true
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
_, err := client.GetEntity(context.Background(), connect.NewRequest(&apiv1.GetEntityRequest{
Type: "host",
UniqueKey: "0",
}))
require.Error(t, err)
assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
}
func TestGetEntityReturnsRelatedActionsForAdmin(t *testing.T) {
setupHostEntityTestData(t)
cfg, _, adminUser := buildRelatedActionsTestConfig(t)
cfg.AuthHttpHeaderUsername = "X-Ot-User"
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
req := connect.NewRequest(&apiv1.GetEntityRequest{
Type: "host",
UniqueKey: "0",
})
req.Header().Set("X-Ot-User", adminUser.Username)
resp, err := client.GetEntity(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, resp.Msg)
assert.Equal(t, "&#128272;", resp.Msg.Icon)
foundPlaybook := false
for _, related := range resp.Msg.RelatedActions {
if related.Action != nil && related.Action.BindingId == "run_playbook" {
foundPlaybook = true
assert.Equal(t, "192.168.66.8", related.PrefilledArguments["ansible_host"])
}
}
assert.True(t, foundPlaybook, "admin should see argument-entity related action in GetEntity response")
}

View File

@ -9,7 +9,10 @@ import (
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
"github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities"
"github.com/OliveTin/OliveTin/internal/executor" "github.com/OliveTin/OliveTin/internal/executor"
"github.com/OliveTin/OliveTin/internal/tpl"
log "github.com/sirupsen/logrus"
) )
func validateJustificationRequired(action *config.Action, justification string, user *authpublic.AuthenticatedUser) error { func validateJustificationRequired(action *config.Action, justification string, user *authpublic.AuthenticatedUser) error {
@ -21,7 +24,7 @@ func validateJustificationRequired(action *config.Action, justification string,
} }
func actionRequiresJustificationConfig(action *config.Action) bool { func actionRequiresJustificationConfig(action *config.Action) bool {
return action != nil && action.Justification return action != nil && action.RequiresJustification()
} }
func justificationProvided(justification string, user *authpublic.AuthenticatedUser) bool { func justificationProvided(justification string, user *authpublic.AuthenticatedUser) bool {
@ -40,6 +43,56 @@ func startActionArgumentsFromProto(args []*apiv1.StartActionArgument) map[string
return result return result
} }
func resolveStartJustification(action *config.Action, binding *executor.ActionBinding, clientJustification string, args map[string]string) string {
if strings.TrimSpace(clientJustification) != "" {
return clientJustification
}
return resolveJustificationFromTemplate(action, binding, clientJustification, args)
}
func resolveJustificationFromTemplate(action *config.Action, binding *executor.ActionBinding, fallback string, args map[string]string) string {
templateText := action.JustificationTemplateText()
if templateText == "" {
return fallback
}
resolved, err := tpl.ParseTemplateWithActionContext(templateText, bindingEntity(binding), args)
if err != nil {
log.WithFields(justificationTemplateErrorFields(templateText, binding, err)).Warn("Failed to resolve justification template")
return fallback
}
return resolved
}
func justificationTemplateErrorFields(templateText string, binding *executor.ActionBinding, err error) log.Fields {
fields := log.Fields{
"template": templateText,
"error": err,
}
entity := bindingEntity(binding)
if entity == nil {
return fields
}
fields["entityKey"] = entity.UniqueKey
if binding.Action != nil && binding.Action.Entity != "" {
fields["entityType"] = binding.Action.Entity
}
return fields
}
func bindingEntity(binding *executor.ActionBinding) *entities.Entity {
if binding == nil {
return nil
}
return binding.Entity
}
func restartRequiresJustificationError() error { func restartRequiresJustificationError() error {
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("justification is required for this action; use StartAction with a justification instead")) return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("justification is required for this action; use StartAction with a justification instead"))
} }

View File

@ -3,7 +3,6 @@ package api
import ( import (
"context" "context"
"testing" "testing"
"time"
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/google/uuid" "github.com/google/uuid"
@ -21,7 +20,7 @@ func TestStartActionRequiresJustificationForGuest(t *testing.T) {
action := &config.Action{ action := &config.Action{
Title: "Send email", Title: "Send email",
ID: "send_email", ID: "send_email",
Justification: true, Justification: config.JustificationRequiredNoTemplate,
Shell: "echo done", Shell: "echo done",
} }
cfg.Actions = append(cfg.Actions, action) cfg.Actions = append(cfg.Actions, action)
@ -49,19 +48,15 @@ func TestStartActionRequiresJustificationForGuest(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, resp.Msg.ExecutionTrackingId) require.NotEmpty(t, resp.Msg.ExecutionTrackingId)
time.Sleep(200 * time.Millisecond) waitForLogJustification(t, ex, resp.Msg.ExecutionTrackingId, "New user registration foo@example.com")
entry, ok := ex.GetLog(resp.Msg.ExecutionTrackingId)
require.True(t, ok)
assert.Equal(t, "New user registration foo@example.com", entry.Justification)
} }
func TestBuildActionExposesJustificationFlag(t *testing.T) { func TestBuildActionExposesJustificationTemplate(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
action := &config.Action{ action := &config.Action{
Title: "Audited action", Title: "Audited action",
ID: "audited", ID: "audited",
Justification: true, Justification: "{{ target }}",
Shell: "echo hi", Shell: "echo hi",
} }
cfg.Actions = append(cfg.Actions, action) cfg.Actions = append(cfg.Actions, action)
@ -77,12 +72,94 @@ func TestBuildActionExposesJustificationFlag(t *testing.T) {
}) })
require.NotNil(t, pb) require.NotNil(t, pb)
assert.True(t, pb.Justification) assert.Equal(t, "{{ target }}", pb.Justification)
}
func TestBuildActionExposesBlankRequiredJustification(t *testing.T) {
cfg := config.DefaultConfig()
action := &config.Action{
Title: "Audited action",
ID: "audited",
Justification: config.JustificationRequiredNoTemplate,
Shell: "echo hi",
}
cfg.Actions = append(cfg.Actions, action)
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(action)
require.NotNil(t, binding)
pb := buildAction(binding, &DashboardRenderRequest{
cfg: cfg,
ex: ex,
})
require.NotNil(t, pb)
assert.Equal(t, config.JustificationRequiredNoTemplate, pb.Justification)
}
func TestResolveStartJustificationUsesTemplateWhenClientValueEmpty(t *testing.T) {
action := &config.Action{
Justification: "{{ ansible_host }}",
}
binding := &executor.ActionBinding{}
got := resolveStartJustification(action, binding, "", map[string]string{
"ansible_host": "192.168.66.8",
})
assert.Equal(t, "192.168.66.8", got)
}
func TestResolveStartJustificationPrefersClientValue(t *testing.T) {
action := &config.Action{
Justification: "{{ ansible_host }}",
}
binding := &executor.ActionBinding{}
got := resolveStartJustification(action, binding, "manual reason", map[string]string{
"ansible_host": "192.168.66.8",
})
assert.Equal(t, "manual reason", got)
}
func TestStartActionResolvesJustificationTemplateForGuest(t *testing.T) {
cfg := config.DefaultConfig()
action := &config.Action{
Title: "Run playbook",
ID: "run_playbook",
Justification: "{{ ansible_host }}",
Shell: "echo done",
Arguments: []config.ActionArgument{
{Name: "ansible_host", Title: "Host"},
},
}
cfg.Actions = append(cfg.Actions, action)
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(action)
require.NotNil(t, binding)
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
resp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: binding.ID,
UniqueTrackingId: uuid.NewString(),
Arguments: []*apiv1.StartActionArgument{
{Name: "ansible_host", Value: "stuffbox"},
},
}))
require.NoError(t, err)
require.NotEmpty(t, resp.Msg.ExecutionTrackingId)
waitForLogJustification(t, ex, resp.Msg.ExecutionTrackingId, "stuffbox")
} }
func TestValidateJustificationRequiredAllowsSystemUser(t *testing.T) { func TestValidateJustificationRequiredAllowsSystemUser(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
action := &config.Action{Title: "Cron job", Justification: true} action := &config.Action{Title: "Cron job", Justification: config.JustificationRequiredNoTemplate}
err := validateJustificationRequired(action, "", auth.UserFromSystem(cfg, "cron")) err := validateJustificationRequired(action, "", auth.UserFromSystem(cfg, "cron"))
require.NoError(t, err) require.NoError(t, err)

View File

@ -47,7 +47,7 @@ func restartArgumentsIncompleteError() error {
} }
func validateRestartLogEntry(entry *executor.InternalLogEntry) error { func validateRestartLogEntry(entry *executor.InternalLogEntry) error {
if entry.Binding.Action.Justification && strings.TrimSpace(entry.Justification) == "" { if entry.Binding.Action.RequiresJustification() && strings.TrimSpace(entry.Justification) == "" {
return restartRequiresJustificationError() return restartRequiresJustificationError()
} }

View File

@ -40,6 +40,22 @@ func waitForLogArguments(t *testing.T, ex *executor.Executor, trackingID string)
return nil return nil
} }
func waitForLogFinished(t *testing.T, ex *executor.Executor, trackingID string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
entry, ok := ex.GetLog(trackingID)
if ok && entry.ExecutionFinished {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for execution to finish on log %s", trackingID)
}
func waitForLogJustification(t *testing.T, ex *executor.Executor, trackingID, expected string) { func waitForLogJustification(t *testing.T, ex *executor.Executor, trackingID, expected string) {
t.Helper() t.Helper()
@ -166,6 +182,7 @@ func TestRestartActionReusesStoredArguments(t *testing.T) {
originalArgs := waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId) originalArgs := waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
assert.Equal(t, "server-a", originalArgs["host"]) assert.Equal(t, "server-a", originalArgs["host"])
waitForLogFinished(t, ex, startResp.Msg.ExecutionTrackingId)
restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{ restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId, ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
@ -286,7 +303,7 @@ func TestRestartActionRequiresJustificationWhenMissingFromStoredLog(t *testing.T
Title: "Dangerous action", Title: "Dangerous action",
Shell: "echo ok", Shell: "echo ok",
MaxConcurrent: 1, MaxConcurrent: 1,
Justification: true, Justification: config.JustificationRequiredNoTemplate,
}, },
} }
@ -319,7 +336,7 @@ func TestRestartActionReusesStoredJustificationViaStartActionPath(t *testing.T)
Title: "Dangerous action", Title: "Dangerous action",
Shell: "echo ok", Shell: "echo ok",
MaxConcurrent: 1, MaxConcurrent: 1,
Justification: true, Justification: config.JustificationRequiredNoTemplate,
}, },
} }
@ -338,6 +355,7 @@ func TestRestartActionReusesStoredJustificationViaStartActionPath(t *testing.T)
require.NoError(t, err) require.NoError(t, err)
waitForLogJustification(t, ex, startResp.Msg.ExecutionTrackingId, "maintenance window") waitForLogJustification(t, ex, startResp.Msg.ExecutionTrackingId, "maintenance window")
waitForLogFinished(t, ex, startResp.Msg.ExecutionTrackingId)
restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{ restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId, ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,

View File

@ -120,6 +120,15 @@ func TestGetActionsAndStart(t *testing.T) {
func TestGetEntities(t *testing.T) { func TestGetEntities(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Entities = []*config.EntityFile{
{
Name: "server",
Properties: []config.EntityProperty{
{Name: "hostname", Title: "Hostname"},
},
},
}
cfg.Sanitize()
ts, client := getNewTestServerAndClient(cfg) ts, client := getNewTestServerAndClient(cfg)
defer ts.Close() defer ts.Close()
@ -138,6 +147,26 @@ func TestGetEntities(t *testing.T) {
validateEntityOrderAndStructure(t, entityDefinitions) validateEntityOrderAndStructure(t, entityDefinitions)
validateNoDuplicates(t, entityDefinitions) validateNoDuplicates(t, entityDefinitions)
validateConsistency(t, client, entityDefinitions) validateConsistency(t, client, entityDefinitions)
validateEntityListProperties(t, client)
}
func validateEntityListProperties(t *testing.T, client apiv1connect.OliveTinApiServiceClient) {
resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
EntityType: "server",
Page: 1,
PageSize: 10,
}))
require.NoError(t, err)
require.Len(t, resp.Msg.EntityDefinitions, 1)
serverDef := resp.Msg.EntityDefinitions[0]
require.NotNil(t, serverDef, "server entity definition should be present")
require.Len(t, serverDef.Properties, 1)
assert.Equal(t, "hostname", serverDef.Properties[0].Name)
assert.Equal(t, "Hostname", serverDef.Properties[0].Title)
assert.Equal(t, int32(3), serverDef.TotalInstances)
require.Len(t, serverDef.Instances, 3)
assert.Equal(t, "alpha.example.com", serverDef.Instances[0].Fields["hostname"])
} }
func setupTestEntities() { func setupTestEntities() {
@ -166,10 +195,8 @@ func validateEntityOrderAndStructure(t *testing.T, entityDefinitions []*apiv1.En
assert.Equal(t, "postgres", entityDefinitions[1].Instances[1].UniqueKey, "Second database instance should be 'postgres' (alphabetically second)") assert.Equal(t, "postgres", entityDefinitions[1].Instances[1].UniqueKey, "Second database instance should be 'postgres' (alphabetically second)")
assert.Equal(t, "server", entityDefinitions[2].Title, "Third entity should be 'server' (alphabetically third)") assert.Equal(t, "server", entityDefinitions[2].Title, "Third entity should be 'server' (alphabetically third)")
assert.Equal(t, 3, len(entityDefinitions[2].Instances), "Server should have 3 instances") assert.Equal(t, 0, len(entityDefinitions[2].Instances), "Server instances should not be included in bulk list response")
assert.Equal(t, "alpha", entityDefinitions[2].Instances[0].UniqueKey, "First server instance should be 'alpha' (alphabetically first)") assert.Equal(t, int32(3), entityDefinitions[2].TotalInstances, "Server should report total instance count")
assert.Equal(t, "beta", entityDefinitions[2].Instances[1].UniqueKey, "Second server instance should be 'beta' (alphabetically second)")
assert.Equal(t, "zebra", entityDefinitions[2].Instances[2].UniqueKey, "Third server instance should be 'zebra' (alphabetically third)")
} }
func validateNoDuplicates(t *testing.T, entityDefinitions []*apiv1.EntityDefinition) { func validateNoDuplicates(t *testing.T, entityDefinitions []*apiv1.EntityDefinition) {
@ -358,6 +385,54 @@ func testWithEntity(t *testing.T, binding *executor.ActionBinding, rr *Dashboard
assert.Equal(t, expectedCanExec, actionResult.CanExec, message) assert.Equal(t, expectedCanExec, actionResult.CanExec, message)
} }
// buildExecWithoutLogsTestConfig returns config for GHSA-jm28-2wcr-qf3h: user "runner" may exec but not read logs.
func buildExecWithoutLogsTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser) {
t.Helper()
cfg := config.DefaultConfig()
cfg.AuthHttpHeaderUsername = "X-Ot-User"
cfg.DefaultPermissions.View = false
cfg.DefaultPermissions.Exec = false
cfg.DefaultPermissions.Logs = false
cfg.Actions = append(cfg.Actions, &config.Action{
ID: "run_only",
Title: "Run Only",
Shell: "echo sensitive-output",
Icon: "🔒",
})
cfg.AccessControlLists = append(cfg.AccessControlLists, &config.AccessControlList{
Name: "runner",
MatchUsernames: []string{"runner"},
AddToEveryAction: true,
Permissions: config.PermissionsList{View: true, Exec: true, Logs: false, Kill: false},
})
runner := &authpublic.AuthenticatedUser{Username: "runner"}
runner.BuildUserAcls(cfg)
return cfg, runner
}
// TestStartActionAndWaitDeniesLogsPermission (GHSA-jm28-2wcr-qf3h) asserts sync execution endpoints
// enforce logs ACL and do not return action output to users allowed to exec but not read logs.
func TestStartActionAndWaitDeniesLogsPermission(t *testing.T) {
cfg, _ := buildExecWithoutLogsTestConfig(t)
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
req := connect.NewRequest(&apiv1.StartActionAndWaitRequest{
ActionId: "run_only",
})
req.Header().Set("X-Ot-User", "runner")
_, err := client.StartActionAndWait(context.Background(), req)
require.Error(t, err)
assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err),
"user with exec:true and logs:false must not receive log output from StartActionAndWait")
}
// buildViewPermissionTestConfig returns config and users for GHSA view-permission tests: // buildViewPermissionTestConfig returns config and users for GHSA view-permission tests:
// one action "secret_action", ACL "restricted" (view:false, logs:false) for user "low", ACL "full" (view:true, logs:true) for user "admin". // one action "secret_action", ACL "restricted" (view:false, logs:false) for user "low", ACL "full" (view:true, logs:true) for user "admin".
func buildViewPermissionTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser, *authpublic.AuthenticatedUser) { func buildViewPermissionTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser, *authpublic.AuthenticatedUser) {
@ -919,3 +994,26 @@ func TestBuildActionIncludesGroups(t *testing.T) {
assert.Equal(t, "missing", actionResult.Groups[1].Name) assert.Equal(t, "missing", actionResult.Groups[1].Name)
assert.Equal(t, int32(0), actionResult.Groups[1].MaxConcurrent) assert.Equal(t, int32(0), actionResult.Groups[1].MaxConcurrent)
} }
func TestBuildChoicesExpandsChecklistEntityChoices(t *testing.T) {
entities.AddEntity("room", "0", map[string]any{"hostname": "attic"})
entities.AddEntity("room", "1", map[string]any{"hostname": "basement"})
t.Cleanup(func() {
entities.ClearEntitiesOfType("room")
})
arg := config.ActionArgument{
Type: "checklist",
Entity: "room",
Choices: []config.ActionArgumentChoice{
{Title: "{{ room.hostname }}", Value: "{{ room.hostname }}"},
},
}
choices := buildChoices(arg)
require.Len(t, choices, 2)
assert.Equal(t, "attic", choices[0].Value)
assert.Equal(t, "attic", choices[0].Title)
assert.Equal(t, "basement", choices[1].Value)
assert.Equal(t, "basement", choices[1].Title)
}

View File

@ -62,8 +62,14 @@ type oauth2State struct {
providerName string providerName string
Username string Username string
Usergroup string Usergroup string
createdAt time.Time
} }
const (
oauthStateMaxAge = 900 // matches olivetin-sid-oauth cookie MaxAge
oauthStateMaxEntries = 10000
)
func assignIfEmpty(target *string, value string) { func assignIfEmpty(target *string, value string) {
if *target == "" { if *target == "" {
*target = value *target = value
@ -129,6 +135,19 @@ func (h *OAuth2Handler) setOAuthCallbackCookie(w http.ResponseWriter, r *http.Re
http.SetCookie(w, cookie) http.SetCookie(w, cookie)
} }
func (h *OAuth2Handler) deleteOAuthStateLocked(state string) {
delete(h.registeredStates, state)
}
func (h *OAuth2Handler) sweepExpiredOAuthStatesLocked(now time.Time) {
cutoff := now.Add(-oauthStateMaxAge * time.Second)
for state, entry := range h.registeredStates {
if entry.createdAt.Before(cutoff) {
delete(h.registeredStates, state)
}
}
}
func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) { func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) {
state, err := randString(16) state, err := randString(16)
@ -147,10 +166,17 @@ func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request)
} }
h.mu.Lock() h.mu.Lock()
h.sweepExpiredOAuthStatesLocked(time.Now())
if len(h.registeredStates) >= oauthStateMaxEntries {
h.mu.Unlock()
http.Error(w, "OAuth login temporarily unavailable", http.StatusServiceUnavailable)
return
}
h.registeredStates[state] = &oauth2State{ h.registeredStates[state] = &oauth2State{
providerConfig: provider, providerConfig: provider,
providerName: providerName, providerName: providerName,
Username: "", Username: "",
createdAt: time.Now(),
} }
h.mu.Unlock() h.mu.Unlock()
@ -177,6 +203,9 @@ func (h *OAuth2Handler) checkOAuthCallbackCookie(w http.ResponseWriter, r *http.
if !h.validateStateMatch(r.URL.Query().Get("state"), state) { if !h.validateStateMatch(r.URL.Query().Get("state"), state) {
log.Errorf("State mismatch: %v != %v", r.URL.Query().Get("state"), state) log.Errorf("State mismatch: %v != %v", r.URL.Query().Get("state"), state)
h.mu.Lock()
h.deleteOAuthStateLocked(state)
h.mu.Unlock()
http.Error(w, "State mismatch", http.StatusBadRequest) http.Error(w, "State mismatch", http.StatusBadRequest)
return nil, state, false return nil, state, false
} }
@ -186,6 +215,9 @@ func (h *OAuth2Handler) checkOAuthCallbackCookie(w http.ResponseWriter, r *http.
h.mu.RUnlock() h.mu.RUnlock()
if !ok { if !ok {
log.Errorf("State not found in server: %v", state) log.Errorf("State not found in server: %v", state)
h.mu.Lock()
h.deleteOAuthStateLocked(state)
h.mu.Unlock()
http.Error(w, "State not found in server", http.StatusBadRequest) http.Error(w, "State not found in server", http.StatusBadRequest)
return nil, state, false return nil, state, false
} }

View File

@ -0,0 +1,66 @@
package otoauth2
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/stretchr/testify/assert"
"golang.org/x/oauth2"
)
func TestSweepExpiredOAuthStatesLocked(t *testing.T) {
h := &OAuth2Handler{
registeredStates: make(map[string]*oauth2State),
}
h.registeredStates["fresh"] = &oauth2State{
providerName: "test",
createdAt: time.Now(),
}
h.registeredStates["stale"] = &oauth2State{
providerName: "test",
createdAt: time.Now().Add(-2 * oauthStateMaxAge * time.Second),
}
h.sweepExpiredOAuthStatesLocked(time.Now())
_, freshFound := h.registeredStates["fresh"]
_, staleFound := h.registeredStates["stale"]
assert.True(t, freshFound)
assert.False(t, staleFound)
}
func TestHandleOAuthLoginRejectsWhenStateMapFull(t *testing.T) {
cfg := config.DefaultConfig()
cfg.AuthOAuth2Providers = map[string]*config.OAuth2Provider{
"test": {
Name: "test",
ClientID: "id",
ClientSecret: "secret",
AuthUrl: "https://example.com/auth",
TokenUrl: "https://example.com/token",
},
}
h := NewOAuth2Handler(cfg)
h.registeredStates = make(map[string]*oauth2State, oauthStateMaxEntries)
for i := 0; i < oauthStateMaxEntries; i++ {
h.registeredStates[strconv.Itoa(i)] = &oauth2State{
providerConfig: &oauth2.Config{},
providerName: "test",
createdAt: time.Now(),
}
}
req := httptest.NewRequest(http.MethodGet, "/oauth/login?provider=test", nil)
rec := httptest.NewRecorder()
h.HandleOAuthLogin(rec, req)
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
assert.Equal(t, oauthStateMaxEntries, len(h.registeredStates))
}

View File

@ -0,0 +1,55 @@
package config
import (
"encoding/json"
"fmt"
"strings"
)
// ParseChecklistValue parses a checklist argument wire value.
// Values must be JSON arrays, or a single choice without commas.
func ParseChecklistValue(value string) ([]string, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil, nil
}
if strings.HasPrefix(trimmed, "[") {
return parseJSONChecklistValue(trimmed)
}
if strings.Contains(trimmed, ",") {
return nil, fmt.Errorf("checklist value uses legacy comma-separated format; use a JSON array instead")
}
return []string{trimmed}, nil
}
func parseJSONChecklistValue(value string) ([]string, error) {
var values []string
if err := json.Unmarshal([]byte(value), &values); err != nil {
return nil, fmt.Errorf("invalid checklist JSON value: %w", err)
}
for _, segment := range values {
if strings.TrimSpace(segment) == "" {
return nil, fmt.Errorf("checklist value contains an empty segment")
}
}
return values, nil
}
// FormatChecklistValue serializes selected checklist values for API transport.
func FormatChecklistValue(values []string) (string, error) {
if len(values) == 0 {
return "", nil
}
encoded, err := json.Marshal(values)
if err != nil {
return "", fmt.Errorf("encoding checklist value: %w", err)
}
return string(encoded), nil
}

View File

@ -0,0 +1,58 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseChecklistValueJSON(t *testing.T) {
t.Parallel()
values, err := ParseChecklistValue(`["documents","photos"]`)
require.NoError(t, err)
assert.Equal(t, []string{"documents", "photos"}, values)
values, err = ParseChecklistValue(`["kitchen,bedroom","hallway"]`)
require.NoError(t, err)
assert.Equal(t, []string{"kitchen,bedroom", "hallway"}, values)
}
func TestParseChecklistValueSingleValue(t *testing.T) {
t.Parallel()
values, err := ParseChecklistValue("documents")
require.NoError(t, err)
assert.Equal(t, []string{"documents"}, values)
}
func TestParseChecklistValueRejectsLegacyCommaSeparated(t *testing.T) {
t.Parallel()
_, err := ParseChecklistValue("documents, photos")
require.Error(t, err)
}
func TestParseChecklistValueRejectsEmptyJSONSegment(t *testing.T) {
t.Parallel()
_, err := ParseChecklistValue(`["documents","","photos"]`)
require.Error(t, err)
}
func TestFormatChecklistValueJSON(t *testing.T) {
t.Parallel()
encoded, err := FormatChecklistValue([]string{"documents", "photos"})
require.NoError(t, err)
assert.Equal(t, `["documents","photos"]`, encoded)
encoded, err = FormatChecklistValue([]string{"kitchen,bedroom"})
require.NoError(t, err)
assert.Equal(t, `["kitchen,bedroom"]`, encoded)
encoded, err = FormatChecklistValue(nil)
require.NoError(t, err)
assert.Empty(t, encoded)
}

View File

@ -7,6 +7,9 @@ import (
// ReservedArgumentNamePrefix is reserved for OliveTin-injected system arguments. // ReservedArgumentNamePrefix is reserved for OliveTin-injected system arguments.
const ReservedArgumentNamePrefix = "ot_" const ReservedArgumentNamePrefix = "ot_"
// JustificationRequiredNoTemplate requires a manual justification with no prefilled template.
const JustificationRequiredNoTemplate = " "
// Action represents the core functionality of OliveTin - commands that show up // Action represents the core functionality of OliveTin - commands that show up
// as buttons in the UI. // as buttons in the UI.
type Action struct { type Action struct {
@ -35,7 +38,23 @@ type Action struct {
SaveLogs SaveLogsConfig `koanf:"saveLogs"` SaveLogs SaveLogsConfig `koanf:"saveLogs"`
EnabledExpression string `koanf:"enabledExpression"` EnabledExpression string `koanf:"enabledExpression"`
Groups []string `koanf:"groups"` Groups []string `koanf:"groups"`
Justification bool `koanf:"justification"` Justification string `koanf:"justification"`
}
func (action *Action) RequiresJustification() bool {
return action != nil && action.Justification != ""
}
func (action *Action) JustificationTemplateText() string {
if !action.RequiresJustification() {
return ""
}
if action.Justification == JustificationRequiredNoTemplate {
return ""
}
return action.Justification
} }
// ActionGroup defines shared limits and metadata for a set of actions. // ActionGroup defines shared limits and metadata for a set of actions.
@ -87,9 +106,16 @@ type WebhookConfig struct {
// Entity represents a "thing" that can have multiple actions associated with it. // Entity represents a "thing" that can have multiple actions associated with it.
// for example, a media player with a start and stop action. // for example, a media player with a start and stop action.
type EntityFile struct { type EntityFile struct {
File string `koanf:"file"` File string `koanf:"file"`
Name string `koanf:"name"` Name string `koanf:"name"`
Icon string `koanf:"icon"` Icon string `koanf:"icon"`
Properties []EntityProperty `koanf:"properties"`
}
// EntityProperty defines a column shown when listing entity instances in the UI.
type EntityProperty struct {
Name string `koanf:"name"`
Title string `koanf:"title"`
} }
// PermissionsList defines what users can do with an action. // PermissionsList defines what users can do with an action.

View File

@ -55,6 +55,7 @@ func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool {
DecoderConfig: &mapstructure.DecoderConfig{ DecoderConfig: &mapstructure.DecoderConfig{
DecodeHook: mapstructure.ComposeDecodeHookFunc( DecodeHook: mapstructure.ComposeDecodeHookFunc(
envDecodeHookFunc, envDecodeHookFunc,
justificationDecodeHookFunc,
mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToTimeDurationHookFunc(),
mapstructure.TextUnmarshallerHookFunc(), mapstructure.TextUnmarshallerHookFunc(),
), ),
@ -259,6 +260,18 @@ func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`) var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`)
func justificationDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) {
if to.Kind() != reflect.String || from.Kind() != reflect.Bool {
return data, nil
}
if data.(bool) {
return JustificationRequiredNoTemplate, nil
}
return "", nil
}
func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) { func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) {
log.Debugf("envDecodeHookFunc called: from=%v, to=%v, data=%v", from, to, data) log.Debugf("envDecodeHookFunc called: from=%v, to=%v, data=%v", from, to, data)
if from.Kind() != reflect.String { if from.Kind() != reflect.String {

View File

@ -0,0 +1,58 @@
package config
import (
"testing"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/rawbytes"
"github.com/knadh/koanf/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestJustificationDecodeHookMigratesLegacyBooleanFalse(t *testing.T) {
cfg := loadJustificationCompatConfig(t, `
actions:
- title: Legacy disabled
shell: echo hi
justification: false
`)
require.Len(t, cfg.Actions, 1)
assert.Empty(t, cfg.Actions[0].Justification)
}
func TestJustificationDecodeHookMigratesLegacyBooleanTrue(t *testing.T) {
cfg := loadJustificationCompatConfig(t, `
actions:
- title: Legacy required
shell: echo hi
justification: true
`)
require.Len(t, cfg.Actions, 1)
assert.Equal(t, JustificationRequiredNoTemplate, cfg.Actions[0].Justification)
}
func TestSanitizeJustificationMigratesWeaklyTypedLegacyStrings(t *testing.T) {
action := &Action{Justification: "false"}
action.sanitizeJustification()
assert.Empty(t, action.Justification)
action.Justification = "true"
action.sanitizeJustification()
assert.Equal(t, JustificationRequiredNoTemplate, action.Justification)
}
func loadJustificationCompatConfig(t *testing.T, yamlBody string) *Config {
t.Helper()
k := koanf.New(".")
require.NoError(t, k.Load(rawbytes.Provider([]byte(yamlBody)), yaml.Parser()))
cfg := DefaultConfig()
require.True(t, unmarshalRoot(k, cfg))
cfg.Sanitize()
return cfg
}

View File

@ -30,10 +30,15 @@ func (cfg *Config) Sanitize() {
cfg.sanitizeActionGroups() cfg.sanitizeActionGroups()
cfg.sanitizeActionGroupReferences() cfg.sanitizeActionGroupReferences()
cfg.sanitizeEntities()
if err := cfg.validateReservedActionArgumentNames(); err != nil { if err := cfg.validateReservedActionArgumentNames(); err != nil {
log.Fatalf("%v", err) log.Fatalf("%v", err)
} }
if err := cfg.validateChecklistChoiceValues(); err != nil {
log.Fatalf("%v", err)
}
} }
func (cfg *Config) validateReservedActionArgumentNames() error { func (cfg *Config) validateReservedActionArgumentNames() error {
@ -60,6 +65,48 @@ func (action *Action) validateReservedArgumentNames() error {
return nil return nil
} }
func (cfg *Config) validateChecklistChoiceValues() error {
for _, action := range cfg.Actions {
if err := action.validateChecklistChoiceValues(); err != nil {
return err
}
}
return nil
}
func (action *Action) validateChecklistChoiceValues() error {
if action == nil {
return nil
}
for _, arg := range action.Arguments {
if err := validateChecklistChoicesForArgument(action.Title, arg); err != nil {
return err
}
}
return nil
}
func validateChecklistChoicesForArgument(actionTitle string, arg ActionArgument) error {
if arg.Type != "checklist" {
return nil
}
for _, choice := range arg.Choices {
if strings.TrimSpace(choice.Value) == "" {
return fmt.Errorf(
`action %q argument %q choice value must not be empty`,
actionTitle,
arg.Name,
)
}
}
return nil
}
func (cfg *Config) sanitizeDashboardsForInlineActions() { func (cfg *Config) sanitizeDashboardsForInlineActions() {
for _, dashboard := range cfg.Dashboards { for _, dashboard := range cfg.Dashboards {
cfg.sanitizeDashboardComponentForInlineActions(dashboard) cfg.sanitizeDashboardComponentForInlineActions(dashboard)
@ -180,6 +227,7 @@ func (action *Action) sanitize(cfg *Config) {
action.ID = getActionID(action) action.ID = getActionID(action)
action.Icon = lookupHTMLIcon(action.Icon, cfg.DefaultIconForActions) action.Icon = lookupHTMLIcon(action.Icon, cfg.DefaultIconForActions)
migrateActionOnClick(action) migrateActionOnClick(action)
action.sanitizeJustification()
action.OnClick = sanitizeOnClick(action.OnClick, cfg) action.OnClick = sanitizeOnClick(action.OnClick, cfg)
action.PopupOnStart = action.OnClick action.PopupOnStart = action.OnClick
@ -243,6 +291,25 @@ func (cfg *Config) sanitizeActionGroupReferences() {
} }
} }
func (cfg *Config) sanitizeEntities() {
for _, entityFile := range cfg.Entities {
if entityFile == nil {
continue
}
entityFile.Icon = lookupHTMLIcon(entityFile.Icon, "")
sanitizeEntityProperties(entityFile)
}
}
func sanitizeEntityProperties(entityFile *EntityFile) {
for idx := range entityFile.Properties {
if entityFile.Properties[idx].Title == "" {
entityFile.Properties[idx].Title = entityFile.Properties[idx].Name
}
}
}
func (cfg *Config) warnInvalidActionGroupReference(action *Action, groupName string) { func (cfg *Config) warnInvalidActionGroupReference(action *Action, groupName string) {
group, found := cfg.ActionGroups[groupName] group, found := cfg.ActionGroups[groupName]
if !found { if !found {
@ -402,6 +469,15 @@ func migrateActionOnClick(action *Action) {
} }
} }
func (action *Action) sanitizeJustification() {
switch action.Justification {
case "false":
action.Justification = ""
case "true":
action.Justification = JustificationRequiredNoTemplate
}
}
func shouldMigrateDefaultOnClickFromPopup(onClick, popupOnStart string) bool { func shouldMigrateDefaultOnClickFromPopup(onClick, popupOnStart string) bool {
if popupOnStart == "" { if popupOnStart == "" {
return false return false
@ -441,10 +517,39 @@ func (arg *ActionArgument) sanitize() {
} }
arg.sanitizeNoType() arg.sanitizeNoType()
arg.sanitizeChecklist()
// Default value validation runs in executor at config load (validateArgumentDefaults). // Default value validation runs in executor at config load (validateArgumentDefaults).
} }
func (arg *ActionArgument) sanitizeChecklist() {
if arg.Type != "checklist" {
return
}
arg.warnMissingChecklistChoices()
arg.warnInvalidChecklistEntityTemplate()
}
func (arg *ActionArgument) warnMissingChecklistChoices() {
if len(arg.Choices) == 0 {
log.WithFields(log.Fields{
"arg": arg.Name,
}).Warn("Checklist argument has no choices defined")
}
}
func (arg *ActionArgument) warnInvalidChecklistEntityTemplate() {
if arg.Entity == "" || len(arg.Choices) == 1 {
return
}
log.WithFields(log.Fields{
"arg": arg.Name,
"entity": arg.Entity,
}).Warn("Checklist argument with entity should define exactly one choice template")
}
func (arg *ActionArgument) sanitizeNoType() { func (arg *ActionArgument) sanitizeNoType() {
if len(arg.Choices) == 0 && arg.Type == "" { if len(arg.Choices) == 0 && arg.Type == "" {
log.WithFields(log.Fields{ log.WithFields(log.Fields{

View File

@ -271,3 +271,25 @@ func TestValidateUniqueLocalUserAPIKeys(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
} }
func TestValidateChecklistChoiceValuesAllowsCommas(t *testing.T) {
t.Parallel()
c := DefaultConfig()
c.Actions = append(c.Actions, &Action{
Title: "Checklist commas",
Shell: "true",
Arguments: []ActionArgument{
{
Name: "segments",
Type: "checklist",
Choices: []ActionArgumentChoice{
{Value: "kitchen,bedroom"},
},
},
},
})
err := c.validateChecklistChoiceValues()
require.NoError(t, err)
}

View File

@ -191,6 +191,10 @@ func typecheckActionArgumentFound(value string, arg *config.ActionArgument) erro
return typecheckNull(arg) return typecheckNull(arg)
} }
if arg.Type == "checklist" {
return typecheckChecklist(value, arg)
}
if len(arg.Choices) > 0 { if len(arg.Choices) > 0 {
return typecheckChoice(value, arg) return typecheckChoice(value, arg)
} }
@ -211,6 +215,8 @@ func TypeSafetyCheck(name string, value string, argumentType string) error {
return nil return nil
case "checkbox": case "checkbox":
return nil return nil
case "checklist":
return nil
case "email": case "email":
return typeSafetyCheckEmail(value) return typeSafetyCheckEmail(value)
case "url": case "url":
@ -230,6 +236,37 @@ func typecheckNull(arg *config.ActionArgument) error {
return nil return nil
} }
func typecheckChecklist(value string, arg *config.ActionArgument) error {
if len(arg.Choices) == 0 {
return fmt.Errorf("checklist argument %q requires choices", arg.Name)
}
segments, err := config.ParseChecklistValue(value)
if err != nil {
return err
}
return typecheckChecklistSegments(segments, arg)
}
func typecheckChecklistSegments(segments []string, arg *config.ActionArgument) error {
for _, segment := range segments {
if err := typecheckChecklistSegment(segment, arg); err != nil {
return err
}
}
return nil
}
func typecheckChecklistSegment(segment string, arg *config.ActionArgument) error {
if segment == "" {
return fmt.Errorf("checklist argument %q contains an empty segment", arg.Name)
}
return typecheckChoice(segment, arg)
}
func typecheckChoice(value string, arg *config.ActionArgument) error { func typecheckChoice(value string, arg *config.ActionArgument) error {
if arg.Entity != "" { if arg.Entity != "" {
return typecheckChoiceEntity(value, arg) return typecheckChoiceEntity(value, arg)
@ -277,11 +314,17 @@ func typeSafetyCheckDatetime(value string) error {
return nil return nil
} }
func anchorCustomRegexPattern(pattern string) string {
return "^(?:" + pattern + ")$"
}
func typeSafetyCheckRegex(name string, value string, argumentType string) error { func typeSafetyCheckRegex(name string, value string, argumentType string) error {
pattern := "" pattern := ""
isCustomRegex := strings.HasPrefix(argumentType, "regex:")
if strings.HasPrefix(argumentType, "regex:") { if isCustomRegex {
pattern = strings.Replace(argumentType, "regex:", "", 1) pattern = strings.TrimPrefix(argumentType, "regex:")
pattern = anchorCustomRegexPattern(pattern)
} else { } else {
found := false found := false
pattern, found = typecheckRegex[argumentType] pattern, found = typecheckRegex[argumentType]
@ -308,21 +351,50 @@ func typeSafetyCheckRegex(name string, value string, argumentType string) error
} }
func typeSafetyCheckUrl(value string) error { func typeSafetyCheckUrl(value string) error {
_, err := url.ParseRequestURI(value) parsed, err := url.ParseRequestURI(value)
if err != nil {
return err
}
return err scheme := strings.ToLower(parsed.Scheme)
if scheme != "http" && scheme != "https" {
return fmt.Errorf("url scheme %q is not allowed; only http and https are permitted", parsed.Scheme)
}
return nil
}
var shellUnsafeArgumentTypes = map[string]struct{}{
"url": {},
"email": {},
"raw_string_multiline": {},
"very_dangerous_raw_string": {},
"password": {},
"html": {},
"confirmation": {},
}
func isUnsafeShellArgumentType(arg *config.ActionArgument) bool {
if strings.HasPrefix(arg.Type, "regex:") {
return true
}
_, inMap := shellUnsafeArgumentTypes[arg.Type]
return inMap || (arg.Type == "checkbox" && len(arg.Choices) == 0)
} }
func checkShellArgumentSafety(action *config.Action) error { func checkShellArgumentSafety(action *config.Action) error {
if action.Shell == "" { if action.Shell == "" {
return nil return nil
} }
unsafe := map[string]struct{}{"url": {}, "email": {}, "raw_string_multiline": {}, "very_dangerous_raw_string": {}, "password": {}}
for _, arg := range action.Arguments { for i := range action.Arguments {
if _, bad := unsafe[arg.Type]; bad { arg := &action.Arguments[i]
if isUnsafeShellArgumentType(arg) {
return fmt.Errorf("unsafe argument type '%s' cannot be used with Shell execution. Use 'exec' instead. See https://docs.olivetin.app/action_execution/shellvsexec.html", arg.Type) return fmt.Errorf("unsafe argument type '%s' cannot be used with Shell execution. Use 'exec' instead. See https://docs.olivetin.app/action_execution/shellvsexec.html", arg.Type)
} }
} }
return nil return nil
} }
@ -333,6 +405,7 @@ func mangleInvalidArgumentValues(req *ExecutionRequest) {
} }
mangleCheckboxValues(req, &arg) mangleCheckboxValues(req, &arg)
mangleChecklistValues(req, &arg)
} }
} }
@ -389,15 +462,20 @@ func MangleArgumentValue(arg *config.ActionArgument, value string, actionTitle s
return value return value
} }
if arg.Type == "datetime" { return mangleArgumentValueByType(arg, value, actionTitle)
}
func mangleArgumentValueByType(arg *config.ActionArgument, value string, actionTitle string) string {
switch arg.Type {
case "datetime":
return mangleDatetimeValue(arg, value, actionTitle) return mangleDatetimeValue(arg, value, actionTitle)
} case "checkbox":
if arg.Type == "checkbox" {
return mangleCheckboxValue(arg, value, actionTitle) return mangleCheckboxValue(arg, value, actionTitle)
case "checklist":
return mangleChecklistValue(arg, value, actionTitle)
default:
return value
} }
return value
} }
func mangleDatetimeValue(arg *config.ActionArgument, value string, actionTitle string) string { func mangleDatetimeValue(arg *config.ActionArgument, value string, actionTitle string) string {
@ -430,6 +508,96 @@ func mangleCheckboxValue(arg *config.ActionArgument, value string, actionTitle s
return value return value
} }
return mangleChoiceSegment(arg, value, actionTitle)
}
func mangleChecklistValues(req *ExecutionRequest, arg *config.ActionArgument) {
if arg.Type != "checklist" {
return
}
value, exists := req.Arguments[arg.Name]
if !exists || value == "" {
return
}
req.Arguments[arg.Name] = mangleChecklistValue(arg, value, req.Binding.Action.Title)
}
func mangleChecklistValue(arg *config.ActionArgument, value string, actionTitle string) string {
if arg == nil || value == "" {
return value
}
segments, err := config.ParseChecklistValue(value)
if err != nil {
return value
}
return mangleChecklistSegments(arg, segments, value, actionTitle)
}
func mangleChecklistSegments(arg *config.ActionArgument, segments []string, fallback string, actionTitle string) string {
mangled := make([]string, len(segments))
for i, segment := range segments {
mangled[i] = mangleChecklistSegment(arg, segment, actionTitle)
}
formatted, err := config.FormatChecklistValue(mangled)
if err != nil {
return fallback
}
return formatted
}
func mangleChecklistSegment(arg *config.ActionArgument, segment string, actionTitle string) string {
trimmed := strings.TrimSpace(segment)
if trimmed == "" {
return ""
}
return mangleChoiceSegment(arg, trimmed, actionTitle)
}
func mangleChoiceSegment(arg *config.ActionArgument, value string, actionTitle string) string {
if mapped, ok := mangleChoiceSegmentEntity(arg, value, actionTitle); ok {
return mapped
}
return mangleChoiceSegmentStatic(arg, value, actionTitle)
}
func mangleChoiceSegmentEntity(arg *config.ActionArgument, value string, actionTitle string) (string, bool) {
if arg.Entity == "" || len(arg.Choices) == 0 {
return value, false
}
return mangleEntityTemplateChoiceSegment(arg.Choices[0], arg.Entity, arg.Name, value, actionTitle)
}
func mangleEntityTemplateChoiceSegment(templateChoice config.ActionArgumentChoice, entityName string, argName string, value string, actionTitle string) (string, bool) {
for _, ent := range entities.GetEntityInstancesOrdered(entityName) {
expandedTitle := tpl.ParseTemplateOfActionBeforeExec(templateChoice.Title, ent)
if value != expandedTitle {
continue
}
expandedValue := tpl.ParseTemplateOfActionBeforeExec(templateChoice.Value, ent)
log.WithFields(log.Fields{
"arg": argName,
"oldValue": value,
"newValue": expandedValue,
"actionTitle": actionTitle,
}).Infof("Mangled entity choice segment")
return expandedValue, true
}
return value, false
}
func mangleChoiceSegmentStatic(arg *config.ActionArgument, value string, actionTitle string) string {
for _, choice := range arg.Choices { for _, choice := range arg.Choices {
if value == choice.Title { if value == choice.Title {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
@ -437,7 +605,7 @@ func mangleCheckboxValue(arg *config.ActionArgument, value string, actionTitle s
"oldValue": value, "oldValue": value,
"newValue": choice.Value, "newValue": choice.Value,
"actionTitle": actionTitle, "actionTitle": actionTitle,
}).Infof("Mangled checkbox value") }).Infof("Mangled choice segment")
return choice.Value return choice.Value
} }

View File

@ -115,6 +115,169 @@ func TestValidateArgumentCheckboxWithChoices(t *testing.T) {
assert.NotNil(t, err, "Expected unknown checkbox title to be rejected against choices") assert.NotNil(t, err, "Expected unknown checkbox title to be rejected against choices")
} }
func checklistTestArg() config.ActionArgument {
return config.ActionArgument{
Name: "directories",
Type: "checklist",
Choices: []config.ActionArgumentChoice{
{Title: "Documents", Value: "documents"},
{Title: "Photos", Value: "photos"},
{Title: "Music", Value: "music"},
},
}
}
func TestValidateArgumentChecklistSelections(t *testing.T) {
log.SetLevel(log.PanicLevel)
arg := checklistTestArg()
action := config.Action{Title: "Test checklist"}
err := ValidateArgument(&arg, "documents", &action)
assert.Nil(t, err)
err = ValidateArgument(&arg, `["documents","photos"]`, &action)
assert.Nil(t, err)
err = ValidateArgument(&arg, `["documents","unknown"]`, &action)
assert.NotNil(t, err)
}
func TestValidateArgumentChecklistTitleMangling(t *testing.T) {
log.SetLevel(log.PanicLevel)
arg := checklistTestArg()
action := config.Action{Title: "Test checklist title mangling"}
err := ValidateArgument(&arg, `["Documents","Photos"]`, &action)
assert.Nil(t, err)
}
func TestValidateArgumentChecklistEmptySelection(t *testing.T) {
log.SetLevel(log.PanicLevel)
arg := checklistTestArg()
action := config.Action{Title: "Test checklist empty"}
err := ValidateArgument(&arg, "", &action)
assert.Nil(t, err)
arg.RejectNull = true
err = ValidateArgument(&arg, "", &action)
assert.NotNil(t, err)
}
func TestValidateArgumentChecklistWithoutChoices(t *testing.T) {
log.SetLevel(log.PanicLevel)
arg := config.ActionArgument{
Name: "directories",
Type: "checklist",
}
action := config.Action{Title: "Test checklist without choices"}
err := ValidateArgument(&arg, "documents", &action)
assert.NotNil(t, err)
}
func TestValidateArgumentChecklistRejectsEmptySegment(t *testing.T) {
log.SetLevel(log.PanicLevel)
arg := checklistTestArg()
action := config.Action{Title: "Test checklist empty segment"}
err := ValidateArgument(&arg, `["documents","","photos"]`, &action)
assert.NotNil(t, err)
}
func TestMangleArgumentValueChecklist(t *testing.T) {
log.SetLevel(log.PanicLevel)
arg := checklistTestArg()
out := MangleArgumentValue(&arg, `["Documents","Music"]`, "Test action")
assert.Equal(t, `["documents","music"]`, out)
out = MangleArgumentValue(&arg, `["documents","photos"]`, "Test action")
assert.Equal(t, `["documents","photos"]`, out)
}
func checklistEntityTestArg() config.ActionArgument {
return config.ActionArgument{
Name: "rooms",
Type: "checklist",
Entity: "room",
Choices: []config.ActionArgumentChoice{
{Title: "{{ room.hostname }}", Value: "{{ room.hostname }}"},
},
}
}
func TestValidateArgumentChecklistEntitySelections(t *testing.T) {
log.SetLevel(log.PanicLevel)
entities.AddEntity("room", "0", map[string]any{"hostname": "attic"})
entities.AddEntity("room", "1", map[string]any{"hostname": "basement"})
arg := checklistEntityTestArg()
action := config.Action{Title: "Test checklist entity"}
err := ValidateArgument(&arg, "attic", &action)
assert.Nil(t, err)
err = ValidateArgument(&arg, `["attic","basement"]`, &action)
assert.Nil(t, err)
err = ValidateArgument(&arg, `["attic","unknown"]`, &action)
assert.NotNil(t, err)
}
func TestMangleArgumentValueChecklistEntityTitles(t *testing.T) {
log.SetLevel(log.PanicLevel)
entities.AddEntity("room", "0", map[string]any{"hostname": "attic"})
entities.AddEntity("room", "1", map[string]any{"hostname": "basement"})
arg := config.ActionArgument{
Name: "rooms",
Type: "checklist",
Entity: "room",
Choices: []config.ActionArgumentChoice{
{Title: "{{ room.hostname }} room", Value: "{{ room.hostname }}"},
},
}
out := MangleArgumentValue(&arg, `["attic room","basement room"]`, "Test checklist entity titles")
assert.Equal(t, `["attic","basement"]`, out)
}
func TestParseActionArgumentsChecklistEmptySelection(t *testing.T) {
req := newExecRequest()
req.Binding.Action = &config.Action{
Title: "Test checklist empty selection",
Shell: "echo 'Selected segments: {{ segments }}'",
Arguments: []config.ActionArgument{
{
Name: "segments",
Type: "checklist",
Choices: []config.ActionArgumentChoice{
{Value: "kitchen"},
{Value: "bedroom"},
},
},
},
}
req.Arguments = map[string]string{
"segments": "",
}
mangleInvalidArgumentValues(req)
out, err := parseActionArguments(req)
assert.Nil(t, err)
assert.Equal(t, "echo 'Selected segments: '", out)
}
func newExecRequest() *ExecutionRequest { func newExecRequest() *ExecutionRequest {
return &ExecutionRequest{ return &ExecutionRequest{
Arguments: make(map[string]string), Arguments: make(map[string]string),
@ -336,13 +499,72 @@ func TestCheckShellArgumentSafetyWithPasswordAndExec(t *testing.T) {
assert.Nil(t, err) assert.Nil(t, err)
} }
func TestCheckShellArgumentSafetyWithHTML(t *testing.T) {
a1 := config.Action{
Title: "HTML shell",
Shell: "echo {{ body }}",
Arguments: []config.ActionArgument{
{Name: "body", Type: "html"},
},
}
err := checkShellArgumentSafety(&a1)
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "unsafe argument type 'html'")
}
func TestCheckShellArgumentSafetyWithConfirmation(t *testing.T) {
a1 := config.Action{
Title: "Confirm shell",
Shell: "echo ok",
Arguments: []config.ActionArgument{
{Name: "agree", Type: "confirmation"},
},
}
err := checkShellArgumentSafety(&a1)
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "unsafe argument type 'confirmation'")
}
func TestCheckShellArgumentSafetyWithChoicelessCheckbox(t *testing.T) {
a1 := config.Action{
Title: "Checkbox shell",
Shell: "echo {{ flag }}",
Arguments: []config.ActionArgument{
{Name: "flag", Type: "checkbox"},
},
}
err := checkShellArgumentSafety(&a1)
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "unsafe argument type 'checkbox'")
}
func TestCheckShellArgumentSafetyWithCustomRegex(t *testing.T) {
a1 := config.Action{
Title: "Regex shell",
Shell: "curl {{ host }}",
Arguments: []config.ActionArgument{
{Name: "host", Type: "regex:[a-zA-Z0-9.-]+"},
},
}
err := checkShellArgumentSafety(&a1)
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "unsafe argument type 'regex:[a-zA-Z0-9.-]+'")
}
func TestTypeSafetyCheckUrl(t *testing.T) { func TestTypeSafetyCheckUrl(t *testing.T) {
assert.Nil(t, TypeSafetyCheck("test1", "http://google.com", "url"), "Test URL: google.com") assert.Nil(t, TypeSafetyCheck("test1", "http://google.com", "url"), "Test URL: google.com")
assert.Nil(t, TypeSafetyCheck("test2", "http://technowax.net:80?foo=bar", "url"), "Test URL: technowax.net with query arguments") assert.Nil(t, TypeSafetyCheck("test2", "http://technowax.net:80?foo=bar", "url"), "Test URL: technowax.net with query arguments")
assert.Nil(t, TypeSafetyCheck("test3", "http://localhost:80?foo=bar", "url"), "Test URL: localhost with query arguments") assert.Nil(t, TypeSafetyCheck("test3", "http://localhost:80?foo=bar", "url"), "Test URL: localhost with query arguments")
assert.Nil(t, TypeSafetyCheck("test7", "https://example.com/path", "url"), "Test URL: https scheme")
assert.NotNil(t, TypeSafetyCheck("test4", "http://lo host:80", "url"), "Test a badly formed URL") assert.NotNil(t, TypeSafetyCheck("test4", "http://lo host:80", "url"), "Test a badly formed URL")
assert.NotNil(t, TypeSafetyCheck("test5", "12345", "url"), "Test a badly formed URL") assert.NotNil(t, TypeSafetyCheck("test5", "12345", "url"), "Test a badly formed URL")
assert.NotNil(t, TypeSafetyCheck("test6", "_!23;", "url"), "Test a badly formed URL") assert.NotNil(t, TypeSafetyCheck("test6", "_!23;", "url"), "Test a badly formed URL")
assert.NotNil(t, TypeSafetyCheck("test8", "file:///etc/passwd", "url"), "file:// scheme must be rejected")
assert.NotNil(t, TypeSafetyCheck("test9", "gopher://example.com", "url"), "gopher:// scheme must be rejected")
} }
func TestTypeSafetyCheckRegex(t *testing.T) { func TestTypeSafetyCheckRegex(t *testing.T) {
@ -367,6 +589,20 @@ func TestTypeSafetyCheckRegex(t *testing.T) {
value: "James1234", value: "James1234",
hasError: true, hasError: true,
}, },
{
name: "GHSA-gvxq - reject partial regex match",
field: "host",
pattern: "regex:[a-zA-Z0-9.-]+",
value: "example.com; id",
hasError: true,
},
{
name: "reject alternation bypass when pattern looks anchored",
field: "host",
pattern: "regex:^safe$|bad",
value: "xxxbad",
hasError: true,
},
} }
for _, tt := range tests { for _, tt := range tests {

View File

@ -1238,24 +1238,70 @@ func stepExecAfter(req *ExecutionRequest) bool {
return true return true
} }
func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout, stderr *bytes.Buffer) (*exec.Cmd, map[string]string, error) { func shellAfterCompletedAction(req *ExecutionRequest) (*config.Action, bool) {
if req == nil {
return nil, false
}
if !hasBindingAndAction(req) {
return nil, false
}
if req.Binding.Action.ShellAfterCompleted == "" { if req.Binding.Action.ShellAfterCompleted == "" {
return nil, false
}
return req.Binding.Action, true
}
func substituteShellAfterCompletedEnvRefs(command string) string {
replacements := []struct{ old, new string }{
{"{{ output }}", `"$OUTPUT"`},
{"{{output}}", `"$OUTPUT"`},
{"{{ exitCode }}", `"$EXITCODE"`},
{"{{exitCode}}", `"$EXITCODE"`},
{"{{ exitCode}}", `"$EXITCODE"`},
{"{{exitCode }}", `"$EXITCODE"`},
}
for _, replacement := range replacements {
command = strings.ReplaceAll(command, replacement.old, replacement.new)
}
return command
}
func parseShellAfterCompletedCommand(req *ExecutionRequest, commandTemplate string, args map[string]string) (string, error) {
finalParsedCommand, err := tpl.ParseTemplateWithActionContext(commandTemplate, req.Binding.Entity, args)
if err != nil {
msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n"
req.mutateLogEntry(func(entry *InternalLogEntry) {
entry.Output += msg
})
log.Warn(msg)
return "", err
}
return finalParsedCommand, nil
}
//gocyclo:ignore
func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout, stderr *bytes.Buffer) (*exec.Cmd, map[string]string, error) {
action, ok := shellAfterCompletedAction(req)
if !ok {
return nil, nil, nil return nil, nil, nil
} }
if hasWebhookTag(req) {
return nil, nil, fmt.Errorf("webhooks cannot use shellAfterCompleted; use exec without after-completion shell instead. See https://docs.olivetin.app/action_execution/shellvsexec.html")
}
args, err := buildShellAfterArgs(req) args, err := buildShellAfterArgs(req)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
finalParsedCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.ShellAfterCompleted, req.Binding.Entity, args) commandTemplate := substituteShellAfterCompletedEnvRefs(action.ShellAfterCompleted)
finalParsedCommand, err := parseShellAfterCompletedCommand(req, commandTemplate, args)
if err != nil { if err != nil {
msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n" return nil, nil, err
req.mutateLogEntry(func(entry *InternalLogEntry) {
entry.Output += msg
})
log.Warn(msg)
return nil, nil, nil
} }
cmd := wrapCommandInShell(ctx, finalParsedCommand) cmd := wrapCommandInShell(ctx, finalParsedCommand)

View File

@ -1,6 +1,8 @@
package executor package executor
import ( import (
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -385,6 +387,67 @@ func TestWebhookAllowsExecExecution(t *testing.T) {
assert.Contains(t, req.logEntry.Output, "hello") assert.Contains(t, req.logEntry.Output, "hello")
} }
func TestWebhookRejectsShellAfterCompleted(t *testing.T) {
cfg := config.DefaultConfig()
e := DefaultExecutor(cfg)
a1 := &config.Action{
Title: "Webhook After Shell Reject",
Exec: []string{"echo", "{{ msg }}"},
ShellAfterCompleted: "echo after",
Arguments: []config.ActionArgument{
{Name: "msg", Type: "ascii"},
},
}
cfg.Actions = append(cfg.Actions, a1)
cfg.Sanitize()
e.RebuildActionMap()
req := ExecutionRequest{
Tags: []string{"webhook"},
AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"),
Cfg: cfg,
Arguments: map[string]string{"msg": "hello"},
Binding: e.FindBindingWithNoEntity(a1),
}
wg, _ := e.ExecRequest(&req)
wg.Wait()
assert.NotNil(t, req.logEntry)
assert.Contains(t, req.logEntry.Output, "webhooks cannot use shellAfterCompleted")
}
func TestShellAfterCompletedUsesOutputEnvSafely(t *testing.T) {
cfg := config.DefaultConfig()
e := DefaultExecutor(cfg)
injectedPath := filepath.Join(t.TempDir(), "olivetin-injected")
expectedMainOutput := "'; touch " + injectedPath + "; echo '"
a1 := &config.Action{
Title: "After completion escape",
Shell: "printf %s \"" + expectedMainOutput + "\"",
ShellAfterCompleted: "printf %s {{ output }}",
}
cfg.Actions = append(cfg.Actions, a1)
cfg.Sanitize()
e.RebuildActionMap()
req := ExecutionRequest{
AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
Cfg: cfg,
Binding: e.FindBindingWithNoEntity(a1),
}
wg, _ := e.ExecRequest(&req)
wg.Wait()
assert.NotNil(t, req.logEntry)
assert.Equal(t, int32(0), req.logEntry.ExitCode)
assert.True(t, strings.HasPrefix(req.logEntry.Output, expectedMainOutput))
assert.Contains(t, req.logEntry.Output, "OliveTin::shellAfterCompleted stdout\n"+expectedMainOutput)
_, err := os.Stat(injectedPath)
assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands from output")
}
func TestFilterToDefinedArgumentsOnly(t *testing.T) { func TestFilterToDefinedArgumentsOnly(t *testing.T) {
req := newExecRequest() req := newExecRequest()
req.Binding.Action = &config.Action{ req.Binding.Action = &config.Action{

View File

@ -45,7 +45,7 @@ func ResolveJustification(req *ExecutionRequest) string {
} }
func actionRequiresJustification(req *ExecutionRequest) bool { func actionRequiresJustification(req *ExecutionRequest) bool {
return req != nil && req.Binding != nil && req.Binding.Action != nil && req.Binding.Action.Justification return req != nil && req.Binding != nil && req.Binding.Action != nil && req.Binding.Action.RequiresJustification()
} }
func defaultJustificationForRequest(req *ExecutionRequest) string { func defaultJustificationForRequest(req *ExecutionRequest) string {

View File

@ -11,7 +11,7 @@ import (
func TestResolveJustificationUsesProvidedValue(t *testing.T) { func TestResolveJustificationUsesProvidedValue(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
action := &config.Action{Title: "Send email", Justification: true, Shell: "echo hi"} action := &config.Action{Title: "Send email", Justification: config.JustificationRequiredNoTemplate, Shell: "echo hi"}
cfg.Actions = append(cfg.Actions, action) cfg.Actions = append(cfg.Actions, action)
ex := DefaultExecutor(cfg) ex := DefaultExecutor(cfg)
ex.RebuildActionMap() ex.RebuildActionMap()
@ -29,7 +29,7 @@ func TestResolveJustificationUsesProvidedValue(t *testing.T) {
func TestResolveJustificationCronDefault(t *testing.T) { func TestResolveJustificationCronDefault(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
action := &config.Action{Title: "Nightly backup", Justification: true, Shell: "echo hi"} action := &config.Action{Title: "Nightly backup", Justification: config.JustificationRequiredNoTemplate, Shell: "echo hi"}
cfg.Actions = append(cfg.Actions, action) cfg.Actions = append(cfg.Actions, action)
ex := DefaultExecutor(cfg) ex := DefaultExecutor(cfg)
ex.RebuildActionMap() ex.RebuildActionMap()
@ -45,7 +45,7 @@ func TestResolveJustificationCronDefault(t *testing.T) {
func TestResolveJustificationStartupDefault(t *testing.T) { func TestResolveJustificationStartupDefault(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
action := &config.Action{Title: "Init", Justification: true, Shell: "echo hi"} action := &config.Action{Title: "Init", Justification: config.JustificationRequiredNoTemplate, Shell: "echo hi"}
cfg.Actions = append(cfg.Actions, action) cfg.Actions = append(cfg.Actions, action)
ex := DefaultExecutor(cfg) ex := DefaultExecutor(cfg)
ex.RebuildActionMap() ex.RebuildActionMap()
@ -61,7 +61,7 @@ func TestResolveJustificationStartupDefault(t *testing.T) {
func TestResolveJustificationWebhookDefault(t *testing.T) { func TestResolveJustificationWebhookDefault(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
action := &config.Action{Title: "Deploy", Justification: true, Exec: []string{"echo", "deploy"}} action := &config.Action{Title: "Deploy", Justification: config.JustificationRequiredNoTemplate, Exec: []string{"echo", "deploy"}}
cfg.Actions = append(cfg.Actions, action) cfg.Actions = append(cfg.Actions, action)
ex := DefaultExecutor(cfg) ex := DefaultExecutor(cfg)
ex.RebuildActionMap() ex.RebuildActionMap()
@ -95,7 +95,7 @@ func TestJustificationNotPassedToShellArgs(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
action := &config.Action{ action := &config.Action{
Title: "Echo", Title: "Echo",
Justification: true, Justification: config.JustificationRequiredNoTemplate,
Shell: "echo {{ message }}", Shell: "echo {{ message }}",
Arguments: []config.ActionArgument{ Arguments: []config.ActionArgument{
{Name: "message", Type: "ascii_sentence"}, {Name: "message", Type: "ascii_sentence"},

View File

@ -79,6 +79,29 @@ func TestStorableArgumentsFromRequestStoresMangledCheckboxValue(t *testing.T) {
assert.Equal(t, "1", args["mode"]) assert.Equal(t, "1", args["mode"])
} }
func TestStorableArgumentsFromRequestStoresMangledChecklistValue(t *testing.T) {
req := newExecRequest()
req.Binding.Action.Arguments = []config.ActionArgument{
{
Name: "directories",
Type: "checklist",
Choices: []config.ActionArgumentChoice{
{Title: "Documents", Value: "documents"},
{Title: "Photos", Value: "photos"},
},
},
}
req.Arguments = map[string]string{
"directories": `["Documents","Photos"]`,
}
mangleInvalidArgumentValues(req)
args := storableArgumentsFromRequest(req)
require.Len(t, args, 1)
assert.Equal(t, `["documents","photos"]`, args["directories"])
}
func TestCopyStorableArgumentsToLogEntry(t *testing.T) { func TestCopyStorableArgumentsToLogEntry(t *testing.T) {
req := newExecRequest() req := newExecRequest()
req.logEntry = &InternalLogEntry{} req.logEntry = &InternalLogEntry{}

View File

@ -147,7 +147,7 @@ actions:
# Using a path under the user's home is more natural on macOS. # Using a path under the user's home is more natural on macOS.
- title: Delete old backups - title: Delete old backups
icon: ashtonished icon: ashtonished
justification: true justification: " "
shell: rm -rf "$HOME/Backups/old/" shell: rm -rf "$HOME/Backups/old/"
arguments: arguments:
- name: confirm - name: confirm