feat: Alpha support for search (behind feature gate) (#1091)

This commit is contained in:
James Read 2026-08-06 22:59:16 +01:00 committed by GitHub
commit 27a451409e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 3467 additions and 858 deletions

View File

@ -37,6 +37,17 @@ The following notes might be helpful when reporting a vulnerability:
* GitHub usernames are how we you will be credited for discoveries reported via GitHub, if using emails we'll ask for your preferred name/handle to credit you with. * GitHub usernames are how we you will be credited for discoveries reported via GitHub, if using emails we'll ask for your preferred name/handle to credit you with.
* CVEs will be requested via GitHub Security Advisories when appropriate, but we do not guarantee that all vulnerabilities will receive CVEs, as this is determined on a case-by-case basis. * CVEs will be requested via GitHub Security Advisories when appropriate, but we do not guarantee that all vulnerabilities will receive CVEs, as this is determined on a case-by-case basis.
## Feature flags (alpha / experimental)
OliveTin uses global `features.*` flags in `config.yaml` to ship unfinished or experimental functionality.
* **All feature flags default to off.** Enabling a flag is an explicit operator choice.
* Functionality behind a feature flag is **alpha / experimental** until the flag is removed or the feature is graduated to a stable, default-on product surface.
* Private security reports **are accepted** for vulnerabilities that affect feature-flagged (alpha) functionality when that flag is enabled. Use Option A or B above; do not file a public issue that discloses exploit details.
* Reports that affect **stable, non-flagged** code paths remain in scope under this policy, even if a feature flag exists elsewhere in the project.
Operators who enable experimental features should treat them as preview software and avoid relying on them in high-assurance production deployments.
## Disclosure of how vulnerabilities were found ## Disclosure of how vulnerabilities were found
It is incredibly useful to not just patch security vulnerabilities, but also to understand how they were found. If you are able to share this information, it can help us and the community to better understand potential attack vectors and improve the overall security of the project. It is incredibly useful to not just patch security vulnerabilities, but also to understand how they were found. If you are able to share this information, it can help us and the community to better understand potential attack vectors and improve the overall security of the project.

View File

@ -12,6 +12,6 @@ actions:
execOnStartup: true execOnStartup: true
---- ----
IMPORTANT: `hidden` is **not** a security control. It only affects where the action appears in the UI. Users who are allowed to view the action (via `defaultPermissions` or ACLs) can still open its Action Details page, see it in logs, and call APIs such as `GetActionBinding`. To restrict who can see or run an action, use xref:security/acl.adoc[Access Control Lists]. IMPORTANT: `hidden` is **not** a security control. It only affects where the action appears in the UI. Opening Action Details and calling APIs such as `GetActionBinding` require **view** permission; viewing execution logs requires **logs** permission. Those are separate from whether the action is listed on a dashboard. To restrict who can see or run an action, use xref:security/acl.adoc[Access Control Lists].
You can still place a hidden action on a custom dashboard by title if you want a button for it in a specific place. You can still place a hidden action on a custom dashboard by title if you want a button for it in a specific place.

View File

@ -143,6 +143,23 @@ You can add custom JavaScript to OliveTin, which will be executed on every page
If the browser blocks your script or network calls with Content Security Policy errors, see xref:security/content_security_policy.adoc[Content Security Policy headers] for how to adjust or disable the CSP sent by OliveTin. If the browser blocks your script or network calls with Content Security Policy errors, see xref:security/content_security_policy.adoc[Content Security Policy headers] for how to adjust or disable the CSP sent by OliveTin.
[#header-search]
== Header search
Header QuickSearch is an **alpha / experimental** opt-in feature. All `features.*` flags default to **off**. Enabling a flag is an explicit operator choice; see the project https://github.com/OliveTin/OliveTin/blob/main/SECURITY.md[security policy] (feature flags are out of scope for CVEs and security advisories until graduated).
Enable it in `config.yaml`:
[source,yaml]
----
features:
headerSearch: true
----
When enabled, the header shows a search control that can jump to actions, dashboards, entities, and system navigation pages the user is already allowed to see. Refresh the browser after changing the flag so Init picks up the new value.
Other `features.*` flags follow the same pattern: typed booleans under `features`, default false, exposed on Init for the web UI, and treated as alpha until graduated.
== Custom CSS (with a custom theme) == Custom CSS (with a custom theme)
You can customize OliveTin with themes, but it's also possible to write your on very simple theme that contains just a few CSS rules to change the look and feel of OliveTin. This is very useful if you just want to change the colours of OliveTin, or hide a few elements. You can customize OliveTin with themes, but it's also possible to write your on very simple theme that contains just a few CSS rules to change the look and feel of OliveTin. This is very useful if you just want to change the colours of OliveTin, or hide a few elements.

View File

@ -81,3 +81,5 @@ entities:
---- ----
OliveTin expands the template once per entity instance and renders each result as a checkbox. Selected values are still passed as a JSON array string. OliveTin expands the template once per entity instance and renders each result as a checkbox. Selected values are still passed as a JSON array string.
IMPORTANT: Like dropdowns, checklist arguments with `entity` must define **exactly one** choice template. Combining `entity` with multiple static choices is rejected on startup.

View File

@ -62,6 +62,8 @@ This is what it looks like in the web interface;
image::args/dropdown/dropdown-entities.png[] image::args/dropdown/dropdown-entities.png[]
IMPORTANT: Arguments with `entity` must define **exactly one** choice template. Multiple static choices combined with `entity` is invalid configuration and is rejected on startup.
include::partial$args/reject-null.adoc[] include::partial$args/reject-null.adoc[]
== Default values == Default values

View File

@ -60,6 +60,7 @@ All configuration options are covered in the solution sections
| `defaultIconForBack` | The default icon to use for back (from directories). | `«` | Requires Restart | - | `defaultIconForBack` | The default icon to use for back (from directories). | `«` | Requires Restart | -
| `enableCustomJs` | Enable custom JavaScript. | `false` | Live Reloadable, but refreshing the web browser is required. | xref:advanced_configuration/webui.adoc[Custom JS]. | `enableCustomJs` | Enable custom JavaScript. | `false` | Live Reloadable, but refreshing the web browser is required. | xref:advanced_configuration/webui.adoc[Custom JS].
| `themeName` | The theme to use. | `` | Restart recommended | xref:reference/reference_themes_for_users.adoc[Themes]. | `themeName` | The theme to use. | `` | Restart recommended | xref:reference/reference_themes_for_users.adoc[Themes].
| `features.headerSearch` | Enable header QuickSearch (actions, dashboards, entities). Alpha / experimental; all `features.*` default off. | `false` | Live reloadable; refresh the browser after changing. | xref:advanced_configuration/webui.adoc#header-search[Header search].
|=== |===
== Security Configuration == Security Configuration

View File

@ -15,6 +15,8 @@ Entity field values are **not** sanitized for shell safety. If you substitute th
To control which fields appear in the Entities page table and entity details view, configure `properties` on the entity definition in `config.yaml`. See xref:entities/properties.adoc[Entity properties] for details. To control which fields appear in the Entities page table and entity details view, configure `properties` on the entity definition in `config.yaml`. See xref:entities/properties.adoc[Entity properties] for details.
To restrict which users may see an entity type (list, details, search, and related UI), list `acls` on the entity definition. See xref:security/acl.adoc#acls[Access Control Lists] (Entities section). Entity types with no `acls` stay unrestricted.
[source,yaml] [source,yaml]
---- ----
entities: entities:
@ -24,6 +26,8 @@ entities:
- file: /etc/OliveTin/servers.yaml - file: /etc/OliveTin/servers.yaml
name: server name: server
icon: ssh icon: ssh
acls:
- ops
properties: properties:
- name: hostname - name: hostname
title: Hostname title: Hostname

View File

@ -160,6 +160,58 @@ In the example above, guests can open **Public tools**, but **Services** is hidd
NOTE: Action `hidden: true` is not part of the ACL model. It only controls dashboard listing. Restrict who can see or run actions with the permissions above; see xref:action_customization/hidden.adoc[Hidden actions]. NOTE: Action `hidden: true` is not part of the ACL model. It only controls dashboard listing. Restrict who can see or run actions with the permissions above; see xref:action_customization/hidden.adoc[Hidden actions].
== ACLs and Entities
Entity types (entries under `entities` in configuration) can also list `acls`. This controls whether users may see that type in the Entities page, entity details, search hints, dashboard entity fieldsets, and entity-driven argument choices.
* If an entity type has **no** `acls` (or an empty list), it is unrestricted — same as root dashboards without ACLs.
* If an entity type lists one or more `acls`, access uses the same allow-list rules as actions and dashboards: a matching ACL that grants `view`, otherwise `defaultPermissions.view`.
* Only **view** applies to entity types. `exec`, `logs`, and `kill` remain action permissions.
* `addToEveryAction` does **not** apply to entities. List the ACL on the entity definition explicitly when you want to restrict it.
* Access is per **entity type**, not per instance. All instances of a restricted type are hidden together.
* Entity types loaded at runtime without a matching `entities:` entry stay unrestricted for ACL purposes, but Diagnostics reports a configuration warning.
* Arguments that expand choices from an entity type must define **exactly one** choice template. Combining `entity` with multiple static choices is invalid and rejected on startup.
Entity-related actions stay dual-gated:
* Viewing the entity type requires entity `view`.
* Seeing or running a related action still requires that action’s own ACLs (`view` / `exec`).
* Search and dashboards do not show entity-bound actions for types the user cannot view, even if the action ACL alone would allow it.
[source,yaml]
.`config.yaml`
----
defaultPermissions:
view: false
exec: false
accessControlLists:
- name: ops
matchUsergroups:
- ops
permissions:
view: true
exec: true
entities:
- name: printers
file: entities/printers.yaml
- name: servers
file: entities/servers.yaml
acls:
- ops
actions:
- title: Restart {{ name }}
entity: servers
shell: echo restart
acls:
- ops
----
In the example above, guests can see **printers** (unrestricted) but not **servers**. Users in the `ops` group can see **servers** and the Restart actions bound to them.
== ACL Matching - usernames and usergroups. == ACL Matching - usernames and usergroups.
You can match users based on their usergroup which is the most common, but it is also possible to match based on the user's username. You can match users based on their usergroup which is the most common, but it is also possible to match based on the user's username.

View File

@ -12,6 +12,7 @@ OliveTin has a few design choices that should help it's general security posture
* OliveTin listens on just 1 open public port by default (1337). The rest of the ports only listen on `localhost` so you don't have to worry about them in your firewall. * OliveTin listens on just 1 open public port by default (1337). The rest of the ports only listen on `localhost` so you don't have to worry about them in your firewall.
* Standard Linux controls can be used to run OliveTin as non-root, with `sudo` permissions if needed. See the action customization section of these docs for more details. * Standard Linux controls can be used to run OliveTin as non-root, with `sudo` permissions if needed. See the action customization section of these docs for more details.
* Robust code-scanning, code review, and dependency analysis at build-time. OliveTin uses many linters and code checkers, especially on new pull requests. Out dated dependencies are addressed quickly. * Robust code-scanning, code review, and dependency analysis at build-time. OliveTin uses many linters and code checkers, especially on new pull requests. Out dated dependencies are addressed quickly.
* Unfinished or experimental surfaces ship behind global `features.*` flags that **default to off**. Those surfaces are alpha until graduated; they are out of scope for security advisories and CVEs (see https://github.com/OliveTin/OliveTin/blob/main/SECURITY.md[SECURITY.md]). Do not enable experimental flags in high-assurance deployments unless you accept that risk.
== Hardening Recommendations == Hardening Recommendations

View File

@ -18,11 +18,11 @@
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"iconify-icon": "^3.0.2", "iconify-icon": "^3.0.2",
"picocrank": "^1.21.2", "picocrank": "^1.22.1",
"standard": "^17.1.2", "standard": "^17.1.2",
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vite": "^8.1.5", "vite": "^8.2.1",
"vue": "^3.5.40", "vue": "^3.5.41",
"vue-i18n": "^11.4.8", "vue-i18n": "^11.4.8",
"vue-router": "^5.2.0" "vue-router": "^5.2.0"
}, },
@ -133,12 +133,12 @@
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.29.7", "version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.29.7" "@babel/types": "^7.29.8"
}, },
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
@ -148,9 +148,9 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.29.7", "version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.29.7", "@babel/helper-string-parser": "^7.29.7",
@ -409,37 +409,6 @@
"postcss-selector-parser": "^7.1.1" "postcss-selector-parser": "^7.1.1"
} }
}, },
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@ -1110,24 +1079,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -1161,18 +1112,18 @@
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.139.0", "version": "0.143.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1186,9 +1137,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.1.5", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz",
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1202,9 +1153,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.1.5", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz",
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1218,9 +1169,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.1.5", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz",
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1234,9 +1185,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.1.5", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz",
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1250,9 +1201,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.1.5", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz",
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1266,9 +1217,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.1.5", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz",
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1282,9 +1233,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.1.5", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz",
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -1298,9 +1249,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.1.5", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz",
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -1314,9 +1265,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.1.5", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz",
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1330,9 +1281,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.1.5", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz",
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1346,9 +1297,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.1.5", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz",
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1361,28 +1312,10 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.5", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz",
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1396,9 +1329,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.1.5", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz",
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1436,16 +1369,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/esrecurse": { "node_modules/@types/esrecurse": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@ -1524,39 +1447,39 @@
} }
}, },
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.5.40", "version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz",
"integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.7", "@babel/parser": "^7.29.8",
"@vue/shared": "3.5.40", "@vue/shared": "3.5.41",
"entities": "^7.0.1", "entities": "^7.0.1",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
} }
}, },
"node_modules/@vue/compiler-dom": { "node_modules/@vue/compiler-dom": {
"version": "3.5.40", "version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz",
"integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-core": "3.5.40", "@vue/compiler-core": "3.5.41",
"@vue/shared": "3.5.40" "@vue/shared": "3.5.41"
} }
}, },
"node_modules/@vue/compiler-sfc": { "node_modules/@vue/compiler-sfc": {
"version": "3.5.40", "version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz",
"integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.7", "@babel/parser": "^7.29.8",
"@vue/compiler-core": "3.5.40", "@vue/compiler-core": "3.5.41",
"@vue/compiler-dom": "3.5.40", "@vue/compiler-dom": "3.5.41",
"@vue/compiler-ssr": "3.5.40", "@vue/compiler-ssr": "3.5.41",
"@vue/shared": "3.5.40", "@vue/shared": "3.5.41",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"postcss": "^8.5.19", "postcss": "^8.5.19",
@ -1564,13 +1487,13 @@
} }
}, },
"node_modules/@vue/compiler-ssr": { "node_modules/@vue/compiler-ssr": {
"version": "3.5.40", "version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz",
"integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.40", "@vue/compiler-dom": "3.5.41",
"@vue/shared": "3.5.40" "@vue/shared": "3.5.41"
} }
}, },
"node_modules/@vue/devtools-api": { "node_modules/@vue/devtools-api": {
@ -1598,51 +1521,51 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.40", "version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz",
"integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/shared": "3.5.40" "@vue/shared": "3.5.41"
} }
}, },
"node_modules/@vue/runtime-core": { "node_modules/@vue/runtime-core": {
"version": "3.5.40", "version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz",
"integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.40", "@vue/reactivity": "3.5.41",
"@vue/shared": "3.5.40" "@vue/shared": "3.5.41"
} }
}, },
"node_modules/@vue/runtime-dom": { "node_modules/@vue/runtime-dom": {
"version": "3.5.40", "version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz",
"integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.40", "@vue/reactivity": "3.5.41",
"@vue/runtime-core": "3.5.40", "@vue/runtime-core": "3.5.41",
"@vue/shared": "3.5.40", "@vue/shared": "3.5.41",
"csstype": "^3.2.3" "csstype": "^3.2.3"
} }
}, },
"node_modules/@vue/server-renderer": { "node_modules/@vue/server-renderer": {
"version": "3.5.40", "version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz",
"integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-ssr": "3.5.40", "@vue/compiler-ssr": "3.5.41",
"@vue/runtime-dom": "3.5.40", "@vue/runtime-dom": "3.5.41",
"@vue/shared": "3.5.40" "@vue/shared": "3.5.41"
} }
}, },
"node_modules/@vue/shared": { "node_modules/@vue/shared": {
"version": "3.5.40", "version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@xterm/addon-fit": { "node_modules/@xterm/addon-fit": {
@ -1972,9 +1895,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.16", "version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@ -3230,9 +3153,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.4", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -3264,9 +3187,9 @@
} }
}, },
"node_modules/femtocrank": { "node_modules/femtocrank": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/femtocrank/-/femtocrank-2.5.0.tgz", "resolved": "https://registry.npmjs.org/femtocrank/-/femtocrank-2.5.1.tgz",
"integrity": "sha512-plV1HNS/fUzohWJ349kuCBZ3TCfXz7V4F/sY2lVbVWtGXUV+aHxLG6IddAMEf64k2LJ8j0KVrj+nIIKepFaKvg==", "integrity": "sha512-KdNcLMLBS/qwpXryPMp28OlB5OJjsLFL9d8qFSNkeldi/JQqn4Axuv345R6ZqGV0C5Aem8gkecjxdWrlOAS4pQ==",
"license": "AGPL-3.0" "license": "AGPL-3.0"
}, },
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
@ -4432,9 +4355,9 @@
} }
}, },
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"detect-libc": "^2.0.3" "detect-libc": "^2.0.3"
@ -4447,23 +4370,23 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
}, },
"optionalDependencies": { "optionalDependencies": {
"lightningcss-android-arm64": "1.32.0", "lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.32.0", "lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.32.0", "lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.32.0", "lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.32.0" "lightningcss-win32-x64-msvc": "1.33.0"
} }
}, },
"node_modules/lightningcss-android-arm64": { "node_modules/lightningcss-android-arm64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -4481,9 +4404,9 @@
} }
}, },
"node_modules/lightningcss-darwin-arm64": { "node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -4501,9 +4424,9 @@
} }
}, },
"node_modules/lightningcss-darwin-x64": { "node_modules/lightningcss-darwin-x64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -4521,9 +4444,9 @@
} }
}, },
"node_modules/lightningcss-freebsd-x64": { "node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -4541,9 +4464,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm-gnueabihf": { "node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -4561,9 +4484,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm64-gnu": { "node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -4581,9 +4504,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm64-musl": { "node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -4601,9 +4524,9 @@
} }
}, },
"node_modules/lightningcss-linux-x64-gnu": { "node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -4621,9 +4544,9 @@
} }
}, },
"node_modules/lightningcss-linux-x64-musl": { "node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -4641,9 +4564,9 @@
} }
}, },
"node_modules/lightningcss-win32-arm64-msvc": { "node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -4661,9 +4584,9 @@
} }
}, },
"node_modules/lightningcss-win32-x64-msvc": { "node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -4932,9 +4855,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.17",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -5272,19 +5195,19 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picocrank": { "node_modules/picocrank": {
"version": "1.21.2", "version": "1.22.1",
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.21.2.tgz", "resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.22.1.tgz",
"integrity": "sha512-bUw6789jyYCTa9waINvYRHmnfgtoMquUidkJx2yhrDCMGbu2O65Qilkl++57yl5JnFeoZOhiPuQTKMPxMQku4A==", "integrity": "sha512-sgIngxDkDIWI4OnXt6VVAsh/mgWChOvsj6/EWOMvaE5h3YUoCr89c9R9XKltEtNhTE5su+wH28qjKlsHpYR5Dg==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@hugeicons/core-free-icons": "^4.2.2", "@hugeicons/core-free-icons": "^4.2.3",
"@hugeicons/vue": "^1.0.7", "@hugeicons/vue": "^1.0.7",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.8",
"femtocrank": "^2.5.0", "femtocrank": "^2.5.1",
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vite": "^8.1.2", "vite": "^8.2.0",
"vue": "^3.5.39", "vue": "^3.5.40",
"vue-router": "^5.1.0" "vue-router": "^5.2.0"
} }
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
@ -5404,9 +5327,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.22", "version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -5423,7 +5346,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.16", "nanoid": "^3.3.17",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@ -5706,12 +5629,12 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.1.5", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.139.0", "@oxc-project/types": "=0.143.0",
"@rolldown/pluginutils": "^1.0.0" "@rolldown/pluginutils": "^1.0.0"
}, },
"bin": { "bin": {
@ -5721,21 +5644,20 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-android-arm64": "1.2.3",
"@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.2.3",
"@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-darwin-x64": "1.2.3",
"@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.2.3",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.3",
"@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.2.3",
"@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.2.3",
"@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.3",
"@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.2.3",
"@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.2.3",
"@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.2.3",
"@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.2.3",
"@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.2.3",
"@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.2.3"
"@rolldown/binding-win32-x64-msvc": "1.1.5"
} }
}, },
"node_modules/run-parallel": { "node_modules/run-parallel": {
@ -6611,13 +6533,6 @@
"strip-bom": "^3.0.0" "strip-bom": "^3.0.0"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -6916,15 +6831,15 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.1.5", "version": "8.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.33.0",
"picomatch": "^4.0.5", "picomatch": "^4.0.5",
"postcss": "^8.5.17", "postcss": "^8.5.25",
"rolldown": "~1.1.5", "rolldown": "~1.2.1",
"tinyglobby": "^0.2.17" "tinyglobby": "^0.2.17"
}, },
"bin": { "bin": {
@ -6941,7 +6856,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0", "@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.3.0", "@vitejs/devtools": "^0.4.0",
"esbuild": "^0.27.0 || ^0.28.0", "esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0", "jiti": ">=1.21.0",
"less": "^4.0.0", "less": "^4.0.0",
@ -7005,16 +6920,16 @@
} }
}, },
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.40", "version": "3.5.41",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz",
"integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.40", "@vue/compiler-dom": "3.5.41",
"@vue/compiler-sfc": "3.5.40", "@vue/compiler-sfc": "3.5.41",
"@vue/runtime-dom": "3.5.40", "@vue/runtime-dom": "3.5.41",
"@vue/server-renderer": "3.5.40", "@vue/server-renderer": "3.5.41",
"@vue/shared": "3.5.40" "@vue/shared": "3.5.41"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "*" "typescript": "*"

View File

@ -11,7 +11,7 @@
"stylelint-config-standard": "^40.0.0" "stylelint-config-standard": "^40.0.0"
}, },
"scripts": { "scripts": {
"test": "node --test resources/vue/components/*.test.mjs resources/vue/utils/*.test.mjs" "test": "node --test resources/vue/components/*.test.mjs resources/vue/utils/*.test.mjs resources/vue/stores/*.test.mjs"
}, },
"author": "", "author": "",
"parcelIgnore": [ "parcelIgnore": [
@ -32,11 +32,11 @@
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"iconify-icon": "^3.0.2", "iconify-icon": "^3.0.2",
"picocrank": "^1.21.2", "picocrank": "^1.22.1",
"standard": "^17.1.2", "standard": "^17.1.2",
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vite": "^8.1.5", "vite": "^8.2.1",
"vue": "^3.5.40", "vue": "^3.5.41",
"vue-i18n": "^11.4.8", "vue-i18n": "^11.4.8",
"vue-router": "^5.2.0" "vue-router": "^5.2.0"
}, },

View File

@ -335,6 +335,77 @@ export declare type GetDashboardResponse = Message<"olivetin.api.v1.GetDashboard
*/ */
export declare const GetDashboardResponseSchema: GenMessage<GetDashboardResponse>; export declare const GetDashboardResponseSchema: GenMessage<GetDashboardResponse>;
/**
* @generated from message olivetin.api.v1.SearchHints
*/
export declare type SearchHints = Message<"olivetin.api.v1.SearchHints"> & {
/**
* Lightweight titles for client search only. Omits fields, icons, and payloads.
* Dashboards are not included; clients index Init.root_dashboard_entries instead.
*
* @generated from field: repeated olivetin.api.v1.EntitySearchHint entities = 1;
*/
entities: EntitySearchHint[];
/**
* @generated from field: repeated olivetin.api.v1.ActionSearchHint actions = 2;
*/
actions: ActionSearchHint[];
};
/**
* Describes the message olivetin.api.v1.SearchHints.
* Use `create(SearchHintsSchema)` to create a new message.
*/
export declare const SearchHintsSchema: GenMessage<SearchHints>;
/**
* @generated from message olivetin.api.v1.EntitySearchHint
*/
export declare type EntitySearchHint = Message<"olivetin.api.v1.EntitySearchHint"> & {
/**
* @generated from field: string title = 1;
*/
title: string;
/**
* @generated from field: string type = 2;
*/
type: string;
/**
* @generated from field: string unique_key = 3;
*/
uniqueKey: string;
};
/**
* Describes the message olivetin.api.v1.EntitySearchHint.
* Use `create(EntitySearchHintSchema)` to create a new message.
*/
export declare const EntitySearchHintSchema: GenMessage<EntitySearchHint>;
/**
* @generated from message olivetin.api.v1.ActionSearchHint
*/
export declare type ActionSearchHint = Message<"olivetin.api.v1.ActionSearchHint"> & {
/**
* @generated from field: string title = 1;
*/
title: string;
/**
* @generated from field: string binding_id = 2;
*/
bindingId: string;
};
/**
* Describes the message olivetin.api.v1.ActionSearchHint.
* Use `create(ActionSearchHintSchema)` to create a new message.
*/
export declare const ActionSearchHintSchema: GenMessage<ActionSearchHint>;
/** /**
* @generated from message olivetin.api.v1.EffectivePolicy * @generated from message olivetin.api.v1.EffectivePolicy
*/ */
@ -361,6 +432,25 @@ export declare type EffectivePolicy = Message<"olivetin.api.v1.EffectivePolicy">
*/ */
export declare const EffectivePolicySchema: GenMessage<EffectivePolicy>; export declare const EffectivePolicySchema: GenMessage<EffectivePolicy>;
/**
* Features are global opt-in flags from config.yaml features.*.
* All flags default to false and gate alpha / experimental functionality.
*
* @generated from message olivetin.api.v1.Features
*/
export declare type Features = Message<"olivetin.api.v1.Features"> & {
/**
* @generated from field: bool header_search = 1;
*/
headerSearch: boolean;
};
/**
* Describes the message olivetin.api.v1.Features.
* Use `create(FeaturesSchema)` to create a new message.
*/
export declare const FeaturesSchema: GenMessage<Features>;
/** /**
* @generated from message olivetin.api.v1.GetDashboardRequest * @generated from message olivetin.api.v1.GetDashboardRequest
*/ */
@ -1895,6 +1985,21 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
* @generated from field: repeated olivetin.api.v1.RootDashboard root_dashboard_entries = 27; * @generated from field: repeated olivetin.api.v1.RootDashboard root_dashboard_entries = 27;
*/ */
rootDashboardEntries: RootDashboard[]; rootDashboardEntries: RootDashboard[];
/**
* Client-side search index hints. Omitted when login is required
* or features.header_search is false.
* Entities match GetEntities visibility; actions use view ACL.
* Dashboards are indexed client-side from root_dashboard_entries.
*
* @generated from field: olivetin.api.v1.SearchHints search_hints = 28;
*/
searchHints?: SearchHints | undefined;
/**
* @generated from field: olivetin.api.v1.Features features = 29;
*/
features?: Features | undefined;
}; };
/** /**

File diff suppressed because one or more lines are too long

View File

@ -8,6 +8,14 @@
@toggle-sidebar="toggleSidebar" @toggle-sidebar="toggleSidebar"
> >
<template #toolbar> <template #toolbar>
<QuickSearch
v-if="!loginRequired && headerSearchEnabled"
:items="searchIndexItems"
:auto-import-routes="false"
:search-fields="['title', 'description', 'category']"
:max-results="15"
placeholder="Search actions, dashboards, entities…"
/>
<div <div
v-if="bannerMessage" v-if="bannerMessage"
id="banner" id="banner"
@ -193,6 +201,7 @@ import { useRouter } from 'vue-router'
import Sidebar from 'picocrank/vue/components/Sidebar.vue' import Sidebar from 'picocrank/vue/components/Sidebar.vue'
import Navigation from 'picocrank/vue/components/Navigation.vue' import Navigation from 'picocrank/vue/components/Navigation.vue'
import Header from 'picocrank/vue/components/Header.vue' import Header from 'picocrank/vue/components/Header.vue'
import QuickSearch from 'picocrank/vue/components/QuickSearch.vue'
import ConnectionBanner from './components/ConnectionBanner.vue' import ConnectionBanner from './components/ConnectionBanner.vue'
import { connectEventStreamIfNeeded } from '../../js/websocket.js' import { connectEventStreamIfNeeded } from '../../js/websocket.js'
import { HugeiconsIcon } from '@hugeicons/vue' import { HugeiconsIcon } from '@hugeicons/vue'
@ -200,6 +209,7 @@ import { UserCircle02Icon, DashboardSquare01Icon } from '@hugeicons/core-free-ic
import logoUrl from '../../OliveTinLogo.png' import logoUrl from '../../OliveTinLogo.png'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import combinedTranslations from '../../../lang/combined_output.json' import combinedTranslations from '../../../lang/combined_output.json'
import { searchIndexItems, clearSearchIndex, indexSystemNavigation, indexSearchHints, indexRootDashboardEntries } from './stores/searchIndex.js'
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
@ -220,6 +230,8 @@ const showDiagnostics = ref(true)
const showVersionNumber = ref(true) const showVersionNumber = ref(true)
const showLoginLink = ref(true) const showLoginLink = ref(true)
const sectionNavigationStyle = ref('sidebar') const sectionNavigationStyle = ref('sidebar')
const loginRequired = ref(false)
const headerSearchEnabled = ref(false)
const languageDialog = ref(null) const languageDialog = ref(null)
const browserLanguages = ref([]) const browserLanguages = ref([])
@ -305,8 +317,15 @@ function updateHeaderFromInit () {
return return
} }
username.value = window.initResponse.authenticatedUser // Rebuild the in-memory search index from this Init; never persist it.
isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest' clearSearchIndex()
const authenticatedUser = window.initResponse.authenticatedUser
loginRequired.value = !!window.initResponse.loginRequired
headerSearchEnabled.value = !!window.initResponse.features?.headerSearch
username.value = authenticatedUser
isLoggedIn.value = authenticatedUser !== '' && authenticatedUser !== 'guest'
currentVersion.value = window.initResponse.currentVersion currentVersion.value = window.initResponse.currentVersion
pageTitle.value = window.initResponse.pageTitle || 'OliveTin' pageTitle.value = window.initResponse.pageTitle || 'OliveTin'
bannerMessage.value = window.initResponse.bannerMessage || '' bannerMessage.value = window.initResponse.bannerMessage || ''
@ -329,12 +348,17 @@ function updateHeaderFromInit () {
renderNavigation() renderNavigation()
applyTheme() applyTheme()
if (window.initResponse.loginRequired) { if (loginRequired.value) {
connectEventStreamIfNeeded() connectEventStreamIfNeeded()
router.push('/login') router.push('/login')
return return
} }
if (headerSearchEnabled.value) {
indexSearchHints(window.initResponse.searchHints)
indexRootDashboardEntries(getRootDashboardEntries())
}
connectEventStreamIfNeeded() connectEventStreamIfNeeded()
} }
@ -433,6 +457,17 @@ function addSystemNavLinks () {
}) })
} }
if (!loginRequired.value && headerSearchEnabled.value) {
indexRootDashboardEntries(getRootDashboardEntries())
indexSystemNavigation({
showLogs: showLogs.value,
showDiagnostics: showDiagnostics.value,
entitiesTitle: t('nav.entities'),
logsTitle: t('nav.logs'),
diagnosticsTitle: t('nav.diagnostics')
})
}
if (systemLinks.length === 0) { if (systemLinks.length === 0) {
return return
} }

View File

@ -0,0 +1,228 @@
import { reactive, computed } from 'vue'
import {
CellsIcon,
DashboardSquare01Icon,
LeftToRightListDashIcon,
PlayIcon,
Wrench01Icon
} from '@hugeicons/core-free-icons'
export const MAX_SEARCH_HINT_ACTIONS = 100
export const MAX_SEARCH_HINT_DASHBOARDS = 100
export const MAX_SEARCH_HINT_ENTITIES_PER_TYPE = 50
const SOURCE_ENTITIES = 'entities'
const SOURCE_ACTIONS = 'actions'
const SOURCE_DASHBOARDS = 'dashboards'
const SOURCE_NAVIGATION = 'navigation'
// QuickSearch keeps insertion order; prefer entities over actions for equal matches.
const CATEGORY_PRIORITY = {
Navigation: 0,
Dashboards: 1,
Entities: 2,
Actions: 3
}
const state = reactive({
itemsById: {},
sourceToIds: {}
})
export const searchIndexItems = computed(() => {
return Object.values(state.itemsById).sort((a, b) => {
return categoryPriority(a.category) - categoryPriority(b.category)
})
})
function categoryPriority (category) {
return CATEGORY_PRIORITY[category] ?? 50
}
export function clearSearchIndex () {
state.itemsById = {}
state.sourceToIds = {}
}
function replaceSource (source, items) {
const previousIds = state.sourceToIds[source] || []
for (const itemId of previousIds) {
delete state.itemsById[itemId]
}
const nextIds = []
for (const item of items) {
if (!item?.id) {
continue
}
state.itemsById[item.id] = item
nextIds.push(item.id)
}
state.sourceToIds[source] = nextIds
}
export function dashboardRoutePath (title, entityType, entityKey) {
if (!title) {
return '/'
}
if (title === 'Actions' && !entityType && !entityKey) {
return '/'
}
let path = `/dashboards/${title}`
if (entityType && entityKey) {
path += `/${entityType}/${entityKey}`
}
return path
}
/**
* Indexes search hints from Init (entities and actions).
* Dashboards are indexed separately from rootDashboardEntries.
*/
export function indexSearchHints (searchHints) {
replaceSource(SOURCE_ENTITIES, entityItemsFromHints(searchHints?.entities))
replaceSource(SOURCE_ACTIONS, actionItemsFromHints(searchHints?.actions))
}
/**
* Indexes dashboards from Init.rootDashboardEntries (already ACL-filtered).
*/
export function indexRootDashboardEntries (entries) {
replaceSource(SOURCE_DASHBOARDS, dashboardItemsFromRootEntries(entries))
}
function entityItemsFromHints (hints) {
if (!Array.isArray(hints)) {
return []
}
const countsByType = {}
const items = []
for (const hint of hints) {
if (!hint?.uniqueKey || !hint?.type) {
continue
}
const count = countsByType[hint.type] || 0
if (count >= MAX_SEARCH_HINT_ENTITIES_PER_TYPE) {
continue
}
countsByType[hint.type] = count + 1
items.push({
id: `entity:${hint.type}:${hint.uniqueKey}`,
title: hint.title || hint.uniqueKey,
description: hint.type,
category: 'Entities',
type: 'route',
path: `/entity-details/${hint.type}/${hint.uniqueKey}`,
icon: CellsIcon
})
}
return items
}
function actionItemsFromHints (hints) {
if (!Array.isArray(hints)) {
return []
}
const items = []
for (const hint of hints.slice(0, MAX_SEARCH_HINT_ACTIONS)) {
if (!hint?.bindingId) {
continue
}
items.push({
id: `action:${hint.bindingId}`,
title: hint.title || hint.bindingId,
category: 'Actions',
type: 'route',
path: `/action/${hint.bindingId}`,
icon: PlayIcon
})
}
return items
}
function dashboardItemsFromRootEntries (entries) {
if (!Array.isArray(entries)) {
return []
}
const items = []
for (const entry of entries.slice(0, MAX_SEARCH_HINT_DASHBOARDS)) {
if (!entry?.title) {
continue
}
const section = (entry.category || '').trim()
items.push({
id: `dashboard:${entry.title}`,
title: entry.title,
description: section,
category: 'Dashboards',
type: 'route',
path: dashboardRoutePath(entry.title),
icon: DashboardSquare01Icon
})
}
return items
}
/**
* Indexes system navigation destinations that the user can already reach from
* the sidebar, respecting Init visibility flags.
*/
export function indexSystemNavigation ({
showLogs = false,
showDiagnostics = false,
entitiesTitle = 'Entities',
logsTitle = 'Logs',
diagnosticsTitle = 'Diagnostics'
} = {}) {
const items = [{
id: 'nav:entities',
title: entitiesTitle,
category: 'Navigation',
type: 'route',
path: '/entities',
icon: CellsIcon
}]
if (showLogs) {
items.push({
id: 'nav:logs',
title: logsTitle,
category: 'Navigation',
type: 'route',
path: '/logs',
icon: LeftToRightListDashIcon
})
}
if (showDiagnostics) {
items.push({
id: 'nav:diagnostics',
title: diagnosticsTitle,
category: 'Navigation',
type: 'route',
path: '/diagnostics',
icon: Wrench01Icon
})
}
replaceSource(SOURCE_NAVIGATION, items)
}

View File

@ -0,0 +1,197 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
MAX_SEARCH_HINT_ACTIONS,
MAX_SEARCH_HINT_DASHBOARDS,
MAX_SEARCH_HINT_ENTITIES_PER_TYPE,
clearSearchIndex,
dashboardRoutePath,
indexRootDashboardEntries,
indexSearchHints,
indexSystemNavigation,
searchIndexItems
} from '../stores/searchIndex.js'
function resetIndex () {
clearSearchIndex()
}
test('dashboardRoutePath maps Actions to root', () => {
assert.equal(dashboardRoutePath('Actions'), '/')
})
test('dashboardRoutePath builds dashboard and entity paths', () => {
assert.equal(dashboardRoutePath('Servers'), '/dashboards/Servers')
assert.equal(
dashboardRoutePath('Servers', 'host', 'web01'),
'/dashboards/Servers/host/web01'
)
})
test('indexSearchHints indexes entities and actions', () => {
resetIndex()
indexSearchHints({
entities: [
{ title: 'web01', type: 'host', uniqueKey: '0' },
{ title: '', type: 'host', uniqueKey: '1' },
{ title: 'skip', type: '', uniqueKey: 'x' }
],
actions: [
{ title: 'Ping Host', bindingId: 'bind-ping' },
{ title: '', bindingId: 'bind-empty-title' },
{ title: 'Ignored', bindingId: '' }
]
})
const byId = Object.fromEntries(searchIndexItems.value.map((item) => [item.id, item]))
assert.equal(byId['entity:host:0'].title, 'web01')
assert.equal(byId['entity:host:0'].path, '/entity-details/host/0')
assert.equal(byId['entity:host:0'].description, 'host')
assert.equal(byId['entity:host:1'].title, '1')
assert.equal(byId['action:bind-ping'].title, 'Ping Host')
assert.equal(byId['action:bind-ping'].path, '/action/bind-ping')
assert.equal(byId['action:bind-ping'].category, 'Actions')
assert.equal(byId['action:bind-empty-title'].title, 'bind-empty-title')
assert.equal(Object.keys(byId).includes('entity:host:x'), false)
assert.equal(Object.keys(byId).includes('action:'), false)
})
test('indexRootDashboardEntries indexes ACL-filtered dashboards', () => {
resetIndex()
indexRootDashboardEntries([
{ title: 'Actions', category: '' },
{ title: 'My Server', category: 'Infrastructure' },
{ title: '', category: 'Ignored' }
])
const byId = Object.fromEntries(searchIndexItems.value.map((item) => [item.id, item]))
assert.equal(byId['dashboard:Actions'].path, '/')
assert.equal(byId['dashboard:My Server'].path, '/dashboards/My Server')
assert.equal(byId['dashboard:My Server'].description, 'Infrastructure')
assert.equal(byId['dashboard:My Server'].category, 'Dashboards')
})
test('indexSearchHints replaces prior entity and action snapshots', () => {
resetIndex()
indexSearchHints({
entities: [{ title: 'old', type: 'host', uniqueKey: 'old' }],
actions: [{ title: 'Old Action', bindingId: 'old-action' }]
})
indexRootDashboardEntries([{ title: 'Old Board', category: '' }])
indexSearchHints({
entities: [{ title: 'new', type: 'host', uniqueKey: 'new' }],
actions: [{ title: 'New Action', bindingId: 'new-action' }]
})
indexRootDashboardEntries([{ title: 'New Board', category: 'Ops' }])
const byId = Object.fromEntries(searchIndexItems.value.map((item) => [item.id, item]))
assert.equal(byId['entity:host:old'], undefined)
assert.equal(byId['action:old-action'], undefined)
assert.equal(byId['dashboard:Old Board'], undefined)
assert.equal(byId['entity:host:new'].title, 'new')
assert.equal(byId['action:new-action'].title, 'New Action')
assert.equal(byId['dashboard:New Board'].description, 'Ops')
})
test('caps actions globally, entities per type, and dashboards from root entries', () => {
resetIndex()
const actions = []
for (let i = 0; i < MAX_SEARCH_HINT_ACTIONS + 20; i++) {
actions.push({ title: `Action ${i}`, bindingId: `a-${i}` })
}
const entities = []
for (let i = 0; i < MAX_SEARCH_HINT_ENTITIES_PER_TYPE + 20; i++) {
entities.push({ title: `Host ${i}`, type: 'host', uniqueKey: `${i}` })
entities.push({ title: `Container ${i}`, type: 'container', uniqueKey: `${i}` })
}
const dashboards = []
for (let i = 0; i < MAX_SEARCH_HINT_DASHBOARDS + 20; i++) {
dashboards.push({ title: `Board ${i}`, category: '' })
}
indexSearchHints({ actions, entities })
indexRootDashboardEntries(dashboards)
const items = searchIndexItems.value
assert.equal(items.filter((item) => item.category === 'Actions').length, MAX_SEARCH_HINT_ACTIONS)
assert.equal(items.filter((item) => item.category === 'Dashboards').length, MAX_SEARCH_HINT_DASHBOARDS)
assert.equal(
items.filter((item) => item.category === 'Entities' && item.description === 'host').length,
MAX_SEARCH_HINT_ENTITIES_PER_TYPE
)
assert.equal(
items.filter((item) => item.category === 'Entities' && item.description === 'container').length,
MAX_SEARCH_HINT_ENTITIES_PER_TYPE
)
})
test('indexSystemNavigation always includes entities and respects visibility flags', () => {
resetIndex()
indexSystemNavigation({
showLogs: true,
showDiagnostics: false,
entitiesTitle: 'Entities',
logsTitle: 'Logs',
diagnosticsTitle: 'Diagnostics'
})
let byId = Object.fromEntries(searchIndexItems.value.map((item) => [item.id, item]))
assert.equal(byId['nav:entities'].path, '/entities')
assert.equal(byId['nav:logs'].path, '/logs')
assert.equal(byId['nav:diagnostics'], undefined)
indexSystemNavigation({
showLogs: false,
showDiagnostics: true
})
byId = Object.fromEntries(searchIndexItems.value.map((item) => [item.id, item]))
assert.equal(byId['nav:entities'].path, '/entities')
assert.equal(byId['nav:logs'], undefined)
assert.equal(byId['nav:diagnostics'].path, '/diagnostics')
})
test('entities appear before actions in searchIndexItems', () => {
resetIndex()
indexSearchHints({
entities: [{ title: 'server1', type: 'host', uniqueKey: '0' }],
actions: [{ title: 'server1', bindingId: 'bind-server1' }]
})
const titlesByCategory = searchIndexItems.value.map((item) => [item.category, item.title])
const entityIndex = titlesByCategory.findIndex(([category]) => category === 'Entities')
const actionIndex = titlesByCategory.findIndex(([category]) => category === 'Actions')
assert.ok(entityIndex >= 0)
assert.ok(actionIndex >= 0)
assert.ok(entityIndex < actionIndex)
})
test('search index has no Logs category', () => {
resetIndex()
indexSearchHints({
entities: [{ title: 'web01', type: 'host', uniqueKey: '0' }],
actions: [{ title: 'Backup', bindingId: 'bind-backup' }]
})
indexRootDashboardEntries([{ title: 'Ops', category: '' }])
indexSystemNavigation({ showLogs: true, showDiagnostics: true })
assert.equal(
searchIndexItems.value.some((item) => item.category === 'Logs'),
false
)
})

View File

@ -24,7 +24,6 @@
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' import EntityDefinitionSection from '../components/EntityDefinitionSection.vue'
const definitionsLoaded = ref(false) const definitionsLoaded = ref(false)
const entityDefinitions = ref([]) const entityDefinitions = ref([])

View File

@ -45,7 +45,6 @@ import { useI18n } from 'vue-i18n'
import Calendar from 'picocrank/vue/components/Calendar.vue' import Calendar from 'picocrank/vue/components/Calendar.vue'
import Section from 'picocrank/vue/components/Section.vue' import Section from 'picocrank/vue/components/Section.vue'
import { loadStoredLogsFilter } from '../utils/logsFilterStorage.js' import { loadStoredLogsFilter } from '../utils/logsFilterStorage.js'
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()

View File

@ -222,7 +222,6 @@ import ActionIconGlyph from '../components/ActionIconGlyph.vue'
import LogActionTitle from '../components/LogActionTitle.vue' import LogActionTitle from '../components/LogActionTitle.vue'
import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js' import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
import { loadStoredLogsFilter, storeLogsFilter } from '../utils/logsFilterStorage.js' import { loadStoredLogsFilter, storeLogsFilter } from '../utils/logsFilterStorage.js'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()

View File

@ -79,12 +79,36 @@ message GetDashboardResponse {
Dashboard dashboard = 4; Dashboard dashboard = 4;
} }
message SearchHints {
// Lightweight titles for client search only. Omits fields, icons, and payloads.
// Dashboards are not included; clients index Init.root_dashboard_entries instead.
repeated EntitySearchHint entities = 1;
repeated ActionSearchHint actions = 2;
}
message EntitySearchHint {
string title = 1;
string type = 2;
string unique_key = 3;
}
message ActionSearchHint {
string title = 1;
string binding_id = 2;
}
message EffectivePolicy { message EffectivePolicy {
bool show_diagnostics = 1; bool show_diagnostics = 1;
bool show_log_list = 2; bool show_log_list = 2;
bool show_version_number = 3; bool show_version_number = 3;
} }
// Features are global opt-in flags from config.yaml features.*.
// All flags default to false and gate alpha / experimental functionality.
message Features {
bool header_search = 1;
}
message GetDashboardRequest { message GetDashboardRequest {
string title = 1; string title = 1;
string entity_type = 2; string entity_type = 2;
@ -426,6 +450,12 @@ message InitResponse {
bool show_navigate_on_start_icons = 25; bool show_navigate_on_start_icons = 25;
int32 config_issue_count = 26; int32 config_issue_count = 26;
repeated RootDashboard root_dashboard_entries = 27; repeated RootDashboard root_dashboard_entries = 27;
// Client-side search index hints. Omitted when login is required
// or features.header_search is false.
// Entities match GetEntities visibility; actions use view ACL.
// Dashboards are indexed client-side from root_dashboard_entries.
SearchHints search_hints = 28;
Features features = 29;
} }
message RootDashboard { message RootDashboard {

File diff suppressed because it is too large Load Diff

View File

@ -139,6 +139,16 @@ func IsAllowedViewDashboard(cfg *config.Config, user *authpublic.AuthenticatedUs
return aclCheck(View, cfg.DefaultPermissions.View, cfg, "isAllowedViewDashboard", user, dashboard.Title, dashboard.Acls, false) return aclCheck(View, cfg.DefaultPermissions.View, cfg, "isAllowedViewDashboard", user, dashboard.Title, dashboard.Acls, false)
} }
// IsAllowedViewEntityType checks if a user may see an entity type (list, details, search).
// Entity types with no acls are unrestricted. AddToEveryAction does not apply.
func IsAllowedViewEntityType(cfg *config.Config, user *authpublic.AuthenticatedUser, entityFile *config.EntityFile) bool {
if entityFile == nil || len(entityFile.Acls) == 0 {
return true
}
return aclCheck(View, cfg.DefaultPermissions.View, cfg, "isAllowedViewEntityType", user, entityFile.Name, entityFile.Acls, false)
}
func isACLRelevant(resourceAcls []string, acl *config.AccessControlList, user *authpublic.AuthenticatedUser, includeAddToEvery bool) bool { func isACLRelevant(resourceAcls []string, acl *config.AccessControlList, user *authpublic.AuthenticatedUser, includeAddToEvery bool) bool {
if !slices.Contains(user.Acls, acl.Name) { if !slices.Contains(user.Acls, acl.Name) {
return false return false

View File

@ -0,0 +1,80 @@
package acl
import (
"testing"
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/stretchr/testify/assert"
)
func TestIsAllowedViewEntityTypeAbsentAclsUnrestricted(t *testing.T) {
cfg := config.DefaultConfig()
cfg.DefaultPermissions.View = false
entityFile := &config.EntityFile{
Name: "printers",
File: "printers.yaml",
}
guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
guest.BuildUserAcls(cfg)
assert.True(t, IsAllowedViewEntityType(cfg, guest, entityFile))
assert.True(t, IsAllowedViewEntityType(cfg, guest, nil))
}
func TestIsAllowedViewEntityTypeAllowDenyAndDefaultFallback(t *testing.T) {
cfg := config.DefaultConfig()
cfg.DefaultPermissions.View = false
cfg.AccessControlLists = []*config.AccessControlList{
{
Name: "ops",
MatchUsernames: []string{"admin"},
Permissions: config.PermissionsList{View: true, Exec: true},
},
}
entityFile := &config.EntityFile{
Name: "servers",
File: "servers.yaml",
Acls: []string{"ops"},
}
guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
guest.BuildUserAcls(cfg)
admin := &authpublic.AuthenticatedUser{Username: "admin"}
admin.BuildUserAcls(cfg)
assert.False(t, IsAllowedViewEntityType(cfg, guest, entityFile))
assert.True(t, IsAllowedViewEntityType(cfg, admin, entityFile))
cfg.DefaultPermissions.View = true
assert.True(t, IsAllowedViewEntityType(cfg, guest, entityFile),
"when no relevant ACL matches, fall back to defaultPermissions.view")
}
func TestIsAllowedViewEntityTypeIgnoresAddToEveryAction(t *testing.T) {
cfg := config.DefaultConfig()
cfg.DefaultPermissions.View = false
cfg.AccessControlLists = []*config.AccessControlList{
{
Name: "admins",
MatchUsernames: []string{"admin"},
AddToEveryAction: true,
Permissions: config.PermissionsList{View: true, Exec: true},
},
}
entityFile := &config.EntityFile{
Name: "secret",
File: "secret.yaml",
Acls: []string{"other"},
}
admin := &authpublic.AuthenticatedUser{Username: "admin"}
admin.BuildUserAcls(cfg)
assert.False(t, IsAllowedViewEntityType(cfg, admin, entityFile),
"AddToEveryAction must not grant entity view without listing the ACL on the entity")
}

View File

@ -161,6 +161,10 @@ 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)
args := startActionArgumentsFromProto(req.Msg.Arguments) args := startActionArgumentsFromProto(req.Msg.Arguments)
if err := api.errUnlessStartEntityAccessAllowed(authenticatedUser, pair, args); err != nil {
return nil, err
}
justification := resolveStartJustification(pair.Action, pair, req.Msg.Justification, args) justification := resolveStartJustification(pair.Action, pair, req.Msg.Justification, args)
if err := validateJustificationRequired(pair.Action, justification, authenticatedUser); err != nil { if err := validateJustificationRequired(pair.Action, justification, authenticatedUser); err != nil {
return nil, connectInvalidJustification(err) return nil, connectInvalidJustification(err)
@ -309,6 +313,10 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request
user := auth.UserFromApiCall(ctx, req, api.cfg) user := auth.UserFromApiCall(ctx, req, api.cfg)
args := startActionArgumentsFromProto(req.Msg.Arguments) args := startActionArgumentsFromProto(req.Msg.Arguments)
if err = api.errUnlessStartEntityAccessAllowed(user, binding, args); err != nil {
return nil, err
}
justification := resolveStartJustification(binding.Action, binding, req.Msg.Justification, args) justification := resolveStartJustification(binding.Action, binding, req.Msg.Justification, args)
if err = validateJustificationRequired(binding.Action, justification, user); err != nil { if err = validateJustificationRequired(binding.Action, justification, user); err != nil {
@ -330,13 +338,18 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[a
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId)) return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
} }
user := auth.UserFromApiCall(ctx, req, api.cfg)
if err := api.errUnlessStartEntityAccessAllowed(user, binding, map[string]string{}); err != nil {
return nil, err
}
args := make(map[string]string) args := make(map[string]string)
execReq := executor.ExecutionRequest{ execReq := executor.ExecutionRequest{
Binding: binding, Binding: binding,
TrackingID: uuid.NewString(), TrackingID: uuid.NewString(),
Arguments: args, Arguments: args,
AuthenticatedUser: auth.UserFromApiCall(ctx, req, api.cfg), AuthenticatedUser: user,
Cfg: api.cfg, Cfg: api.cfg,
} }
@ -377,6 +390,10 @@ func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Re
} }
user := auth.UserFromApiCall(ctx, req, api.cfg) user := auth.UserFromApiCall(ctx, req, api.cfg)
if err := api.errUnlessStartEntityAccessAllowed(user, binding, map[string]string{}); err != nil {
return nil, err
}
logEntry, err := api.startActionByGetAndWaitLogEntry(binding, user) logEntry, err := api.startActionByGetAndWaitLogEntry(binding, user)
if err != nil { if err != nil {
return nil, err return nil, err
@ -586,7 +603,7 @@ func (api *oliveTinAPI) getActionBindingResponse(user *authpublic.AuthenticatedU
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", bindingId)) return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", bindingId))
} }
if !api.userCanViewAction(user, binding.Action) { if !api.userCanViewBinding(user, binding) {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied")) return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
} }
@ -603,6 +620,38 @@ func (api *oliveTinAPI) userCanViewAction(user *authpublic.AuthenticatedUser, ac
return acl.IsAllowedView(api.cfg, user, action) return acl.IsAllowedView(api.cfg, user, action)
} }
func (api *oliveTinAPI) userCanViewEntityType(user *authpublic.AuthenticatedUser, entityType string) bool {
return acl.IsAllowedViewEntityType(api.cfg, user, entityFileForType(api.cfg, entityType))
}
func (api *oliveTinAPI) userCanViewBinding(user *authpublic.AuthenticatedUser, binding *executor.ActionBinding) bool {
if binding == nil || binding.Action == nil {
return false
}
if !api.userCanViewAction(user, binding.Action) {
return false
}
return api.bindingEntityTypeAllowed(user, binding)
}
func (api *oliveTinAPI) bindingEntityTypeAllowed(user *authpublic.AuthenticatedUser, binding *executor.ActionBinding) bool {
if binding == nil || binding.Action == nil || binding.Action.Entity == "" {
return true
}
return api.userCanViewEntityType(user, binding.Action.Entity)
}
func (api *oliveTinAPI) errUnlessBindingEntityTypeAllowed(user *authpublic.AuthenticatedUser, binding *executor.ActionBinding) error {
if api.bindingEntityTypeAllowed(user, binding) {
return nil
}
return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardRequest]) (*connect.Response[apiv1.GetDashboardResponse], error) { func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardRequest]) (*connect.Response[apiv1.GetDashboardResponse], error) {
user := auth.UserFromApiCall(ctx, req, api.cfg) user := auth.UserFromApiCall(ctx, req, api.cfg)
@ -848,7 +897,7 @@ func (api *oliveTinAPI) errUnlessUserMayValidateArgumentTypeForBinding(user *aut
return connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", bindingID)) return connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", bindingID))
} }
if !api.userCanViewAction(user, binding.Action) { if !api.userCanViewBinding(user, binding) {
return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied")) return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
} }
@ -869,9 +918,31 @@ func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Reque
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", req.Msg.BindingId)) return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", req.Msg.BindingId))
} }
if err := api.validateArgumentTypeEntityAccess(user, req.Msg); err != nil {
return nil, err
}
return api.validateArgumentTypeConnectResponse(req.Msg) return api.validateArgumentTypeConnectResponse(req.Msg)
} }
func (api *oliveTinAPI) validateArgumentTypeEntityAccess(user *authpublic.AuthenticatedUser, msg *apiv1.ValidateArgumentTypeRequest) error {
arg := api.argumentFromValidationRequest(msg)
if arg == nil {
return nil
}
return api.errUnlessEntityArgumentAllowed(user, arg, "")
}
func (api *oliveTinAPI) argumentFromValidationRequest(msg *apiv1.ValidateArgumentTypeRequest) *config.ActionArgument {
if msg == nil || msg.BindingId == "" || msg.ArgumentName == "" {
return nil
}
arg, _ := api.findArgumentForValidation(msg.BindingId, msg.ArgumentName)
return arg
}
func (api *oliveTinAPI) validateArgumentTypeConnectResponse(msg *apiv1.ValidateArgumentTypeRequest) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) { func (api *oliveTinAPI) validateArgumentTypeConnectResponse(msg *apiv1.ValidateArgumentTypeRequest) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
err := api.validateArgumentTypeInternal(msg) err := api.validateArgumentTypeInternal(msg)
desc := "" desc := ""
@ -895,6 +966,10 @@ func (api *oliveTinAPI) validateArgumentTypeInternal(msg *apiv1.ValidateArgument
return fmt.Errorf("argument not found") return fmt.Errorf("argument not found")
} }
if err := errUnlessEntityArgumentValueAllowed(arg, msg.Value); err != nil {
return err
}
return executor.ValidateArgument(arg, msg.Value, action) return executor.ValidateArgument(arg, msg.Value, action)
} }
@ -1227,23 +1302,18 @@ func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[api
func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitRequest]) (*connect.Response[apiv1.InitResponse], error) { func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitRequest]) (*connect.Response[apiv1.InitResponse], error) {
user := auth.UserFromApiCall(ctx, req, api.cfg) user := auth.UserFromApiCall(ctx, req, api.cfg)
return connect.NewResponse(api.buildInitResponse(user)), nil
}
func (api *oliveTinAPI) buildInitResponse(user *authpublic.AuthenticatedUser) *apiv1.InitResponse {
loginRequired := user.IsGuest() && api.cfg.AuthRequireGuestsToLogin loginRequired := user.IsGuest() && api.cfg.AuthRequireGuestsToLogin
currentVersion, availableVersion, showNewVersions := initVersionFields(user, api.cfg)
showVersion := user.EffectivePolicy.ShowVersionNumber
currentVersion := ""
availableVersion := ""
if showVersion {
currentVersion = installationinfo.Build.Version
availableVersion = installationinfo.Runtime.AvailableVersion
}
rootDashboardEntries := api.buildRootDashboardEntries(user, api.cfg.Dashboards) rootDashboardEntries := api.buildRootDashboardEntries(user, api.cfg.Dashboards)
res := &apiv1.InitResponse{ res := &apiv1.InitResponse{
ShowFooter: api.cfg.ShowFooter, ShowFooter: api.cfg.ShowFooter,
ShowNavigation: api.cfg.ShowNavigation, ShowNavigation: api.cfg.ShowNavigation,
ShowNewVersions: showVersion && api.cfg.ShowNewVersions, ShowNewVersions: showNewVersions,
AvailableVersion: availableVersion, AvailableVersion: availableVersion,
CurrentVersion: currentVersion, CurrentVersion: currentVersion,
PageTitle: api.cfg.PageTitle, PageTitle: api.cfg.PageTitle,
@ -1268,9 +1338,29 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
AvailableThemes: discoverAvailableThemes(api.cfg), AvailableThemes: discoverAvailableThemes(api.cfg),
ShowNavigateOnStartIcons: api.cfg.ShowNavigateOnStartIcons, ShowNavigateOnStartIcons: api.cfg.ShowNavigateOnStartIcons,
ConfigIssueCount: configIssueCountForUser(api, user), ConfigIssueCount: configIssueCountForUser(api, user),
Features: &apiv1.Features{
HeaderSearch: api.cfg.Features.HeaderSearch,
},
SearchHints: api.initSearchHints(user, loginRequired),
} }
return connect.NewResponse(res), nil return res
}
func initVersionFields(user *authpublic.AuthenticatedUser, cfg *config.Config) (currentVersion string, availableVersion string, showNewVersions bool) {
if !user.EffectivePolicy.ShowVersionNumber {
return "", "", false
}
return installationinfo.Build.Version, installationinfo.Runtime.AvailableVersion, cfg.ShowNewVersions
}
func (api *oliveTinAPI) initSearchHints(user *authpublic.AuthenticatedUser, loginRequired bool) *apiv1.SearchHints {
if loginRequired || !api.cfg.Features.HeaderSearch {
return nil
}
return api.buildSearchHints(user)
} }
// discoverAvailableThemes finds all available themes in the custom-webui/themes directory. // discoverAvailableThemes finds all available themes in the custom-webui/themes directory.
@ -1450,7 +1540,7 @@ func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.
} }
entityMap := entities.GetEntities() entityMap := entities.GetEntities()
entityDefinitions := api.buildEntityDefinitionsResponse(req.Msg, entityMap) entityDefinitions := api.buildEntityDefinitionsResponse(user, req.Msg, entityMap)
res := &apiv1.GetEntitiesResponse{ res := &apiv1.GetEntitiesResponse{
EntityDefinitions: entityDefinitions, EntityDefinitions: entityDefinitions,
@ -1570,6 +1660,10 @@ func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.Ge
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity type %s not found", req.Msg.Type)) return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity type %s not found", req.Msg.Type))
} }
if !api.userCanViewEntityType(user, req.Msg.Type) {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity type %s not found", req.Msg.Type))
}
entity, ok := instances[req.Msg.UniqueKey] entity, ok := instances[req.Msg.UniqueKey]
if !ok { if !ok {
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))
@ -1701,9 +1795,14 @@ func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv
} }
authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg) authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
restartArgs := copyStringMap(execReqLogEntry.Arguments)
if err := api.errUnlessStartEntityAccessAllowed(authenticatedUser, execReqLogEntry.Binding, restartArgs); err != nil {
return nil, err
}
execReq := executor.ExecutionRequest{ execReq := executor.ExecutionRequest{
Binding: execReqLogEntry.Binding, Binding: execReqLogEntry.Binding,
Arguments: copyStringMap(execReqLogEntry.Arguments), Arguments: restartArgs,
Justification: execReqLogEntry.Justification, Justification: execReqLogEntry.Justification,
AuthenticatedUser: authenticatedUser, AuthenticatedUser: authenticatedUser,
Cfg: api.cfg, Cfg: api.cfg,

View File

@ -81,18 +81,42 @@ func (rr *DashboardRenderRequest) findActionForEntity(title string, entity *enti
defer rr.ex.MapActionBindingsLock.RUnlock() defer rr.ex.MapActionBindingsLock.RUnlock()
for _, binding := range rr.ex.MapActionBindings { for _, binding := range rr.ex.MapActionBindings {
if !bindingMatchesTitleAndEntity(binding, title, entity) { if action := rr.actionFromMatchingBinding(title, entity, binding); action != nil {
continue return action
} }
if !acl.IsAllowedView(rr.cfg, rr.AuthenticatedUser, binding.Action) {
return nil
}
return buildAction(binding, rr)
} }
return nil return nil
} }
func (rr *DashboardRenderRequest) actionFromMatchingBinding(title string, entity *entities.Entity, binding *executor.ActionBinding) *apiv1.Action {
if !bindingMatchesTitleAndEntity(binding, title, entity) {
return nil
}
if !rr.canViewBindingForDashboard(binding) {
return nil
}
return buildAction(binding, rr)
}
func (rr *DashboardRenderRequest) canViewBindingForDashboard(binding *executor.ActionBinding) bool {
if binding == nil || binding.Action == nil {
return false
}
if !acl.IsAllowedView(rr.cfg, rr.AuthenticatedUser, binding.Action) {
return false
}
if binding.Action.Entity == "" {
return true
}
return acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, binding.Action.Entity))
}
func matchesEntity(binding *executor.ActionBinding, entity *entities.Entity) bool { func matchesEntity(binding *executor.ActionBinding, entity *entities.Entity) bool {
if entity == nil { if entity == nil {
return binding.Entity == nil return binding.Entity == nil
@ -191,7 +215,7 @@ func applyActiveBindingStateToAction(btn *apiv1.Action, bindingID string, states
btn.HasQueuedInstance = state.hasQueued btn.HasQueuedInstance = state.hasQueued
} }
func buildActionArguments(action *config.Action, entity *entities.Entity) []*apiv1.ActionArgument { func buildActionArguments(action *config.Action, entity *entities.Entity, rr *DashboardRenderRequest) []*apiv1.ActionArgument {
args := make([]*apiv1.ActionArgument, 0, len(action.Arguments)) args := make([]*apiv1.ActionArgument, 0, len(action.Arguments))
for _, cfgArg := range action.Arguments { for _, cfgArg := range action.Arguments {
args = append(args, &apiv1.ActionArgument{ args = append(args, &apiv1.ActionArgument{
@ -200,7 +224,7 @@ func buildActionArguments(action *config.Action, entity *entities.Entity) []*api
Type: cfgArg.Type, Type: cfgArg.Type,
Description: cfgArg.Description, Description: cfgArg.Description,
DefaultValue: getDefaultArgumentValue(cfgArg, entity), DefaultValue: getDefaultArgumentValue(cfgArg, entity),
Choices: buildChoices(cfgArg), Choices: buildChoices(cfgArg, rr),
Suggestions: cfgArg.Suggestions, Suggestions: cfgArg.Suggestions,
SuggestionsBrowserKey: cfgArg.SuggestionsBrowserKey, SuggestionsBrowserKey: cfgArg.SuggestionsBrowserKey,
}) })
@ -228,7 +252,7 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
applyActiveBindingStateToAction(&btn, binding.ID, rr.activeBindingStates) applyActiveBindingStateToAction(&btn, binding.ID, rr.activeBindingStates)
applyActionExecTriggers(&btn, action) applyActionExecTriggers(&btn, action)
btn.Arguments = buildActionArguments(action, binding.Entity) btn.Arguments = buildActionArguments(action, binding.Entity, rr)
btn.Groups = buildActionGroups(action, rr.cfg) btn.Groups = buildActionGroups(action, rr.cfg)
return &btn return &btn
@ -262,15 +286,23 @@ func actionGroupMembershipFromConfig(name string, cfg *config.Config) *apiv1.Act
return membership return membership
} }
func buildChoices(arg config.ActionArgument) []*apiv1.ActionArgumentChoice { func buildChoices(arg config.ActionArgument, rr *DashboardRenderRequest) []*apiv1.ActionArgumentChoice {
if arg.Entity != "" && len(arg.Choices) == 1 { if arg.Entity == "" {
return buildChoicesEntity(arg.Choices[0], arg.Entity)
} else {
return buildChoicesSimple(arg.Choices) return buildChoicesSimple(arg.Choices)
} }
if len(arg.Choices) != 1 {
return []*apiv1.ActionArgumentChoice{}
}
return buildChoicesEntity(arg.Choices[0], arg.Entity, rr)
} }
func buildChoicesEntity(firstChoice config.ActionArgumentChoice, entityTitle string) []*apiv1.ActionArgumentChoice { func buildChoicesEntity(firstChoice config.ActionArgumentChoice, entityTitle string, rr *DashboardRenderRequest) []*apiv1.ActionArgumentChoice {
if rr == nil || !acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, entityTitle)) {
return []*apiv1.ActionArgumentChoice{}
}
ret := []*apiv1.ActionArgumentChoice{} ret := []*apiv1.ActionArgumentChoice{}
for _, ent := range entities.GetEntityInstancesOrdered(entityTitle) { for _, ent := range entities.GetEntityInstancesOrdered(entityTitle) {

View File

@ -5,6 +5,7 @@ import (
"strings" "strings"
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"
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities" "github.com/OliveTin/OliveTin/internal/entities"
) )
@ -14,66 +15,73 @@ const (
maxEntityInstancesPageSize = 100 maxEntityInstancesPageSize = 100
) )
func (api *oliveTinAPI) buildEntityDefinitionsResponse(req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition { func (api *oliveTinAPI) buildEntityDefinitionsResponse(user *authpublic.AuthenticatedUser, req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
if req != nil && req.EntityType != "" { if req != nil && req.EntityType != "" {
return api.buildFilteredEntityDefinitions(req, entityMap) return api.buildFilteredEntityDefinitions(user, req, entityMap)
} }
return api.buildAllEntityDefinitions(entityMap) return api.buildAllEntityDefinitions(user, entityMap)
} }
func (api *oliveTinAPI) buildAllEntityDefinitions(entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition { func (api *oliveTinAPI) buildAllEntityDefinitions(user *authpublic.AuthenticatedUser, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
entityNames := sortedEntityTypeNames(entityMap) entityNames := sortedEntityTypeNames(entityMap)
entityDefinitions := make([]*apiv1.EntityDefinition, 0, len(entityNames)) entityDefinitions := make([]*apiv1.EntityDefinition, 0, len(entityNames))
for _, name := range entityNames { for _, name := range entityNames {
entityFile := entityFileForType(api.cfg, name) if !api.userCanViewEntityType(user, name) {
properties := entityPropertiesFromFile(entityFile) continue
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
} }
def := api.buildEntityDefinition(name, entityMap[name], false, "", 0, 0)
entityDefinitions = append(entityDefinitions, def) entityDefinitions = append(entityDefinitions, def)
} }
return entityDefinitions return entityDefinitions
} }
func (api *oliveTinAPI) buildFilteredEntityDefinitions(req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition { func (api *oliveTinAPI) buildFilteredEntityDefinitions(user *authpublic.AuthenticatedUser, req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
if !api.userCanViewEntityType(user, req.EntityType) {
return nil
}
entityInstances, ok := entityMap[req.EntityType] entityInstances, ok := entityMap[req.EntityType]
if !ok || len(entityInstances) == 0 { if !ok || len(entityInstances) == 0 {
return nil 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) pageSize := normalizeEntityInstancesPageSize(req.PageSize)
page := normalizeEntityInstancesPage(req.Page) page := normalizeEntityInstancesPage(req.Page)
def := api.buildEntityDefinition(req.EntityType, entityInstances, true, req.Filter, page, pageSize)
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} return []*apiv1.EntityDefinition{def}
} }
func (api *oliveTinAPI) buildEntityDefinition(entityType string, entityInstances map[string]*entities.Entity, paginate bool, filter string, page, pageSize int32) *apiv1.EntityDefinition {
entityFile := entityFileForType(api.cfg, entityType)
properties := entityPropertiesFromFile(entityFile)
instances := buildSortedEntityInstances(entityType, entityInstances, properties)
def := &apiv1.EntityDefinition{
Title: entityType,
UsedOnDashboards: findDashboardsForEntity(entityType, api.cfg.Dashboards),
Icon: entityTypeIcon(api.cfg, entityType),
Properties: entityDefinitionProperties(properties),
TotalInstances: int32(len(instances)),
}
if !paginate {
if len(properties) == 0 {
def.Instances = instances
}
return def
}
filtered := filterEntityInstances(instances, filter)
def.TotalInstances = int32(len(filtered))
def.Instances = paginateEntityInstances(filtered, page, pageSize)
return def
}
func sortedEntityTypeNames(entityMap entities.EntitiesByClass) []string { func sortedEntityTypeNames(entityMap entities.EntitiesByClass) []string {
entityNames := make([]string, 0, len(entityMap)) entityNames := make([]string, 0, len(entityMap))
for name := range entityMap { for name := range entityMap {
@ -112,17 +120,28 @@ func filterEntityInstances(instances []*apiv1.Entity, filter string) []*apiv1.En
filtered = append(filtered, instance) filtered = append(filtered, instance)
} }
} }
return filtered return filtered
} }
func entityInstanceMatchesFilter(instance *apiv1.Entity, filter string) bool { func entityInstanceMatchesFilter(instance *apiv1.Entity, filter string) bool {
if strings.Contains(strings.ToLower(instance.Title), filter) { if instance == nil {
return false
}
if stringContainsFold(instance.Title, filter) || stringContainsFold(instance.UniqueKey, filter) {
return true return true
} }
for _, value := range instance.Fields { return entityFieldsContainFilter(instance.Fields, filter)
if strings.Contains(strings.ToLower(value), filter) { }
func stringContainsFold(value, filter string) bool {
return strings.Contains(strings.ToLower(value), strings.ToLower(filter))
}
func entityFieldsContainFilter(fields map[string]string, filter string) bool {
for _, value := range fields {
if stringContainsFold(value, filter) {
return true return true
} }
} }

View File

@ -0,0 +1,214 @@
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 buildEntityAclTestConfig() *config.Config {
cfg := config.DefaultConfig()
cfg.DefaultPermissions.View = false
cfg.DefaultPermissions.Exec = false
cfg.AccessControlLists = []*config.AccessControlList{
{
Name: "ops",
MatchUsernames: []string{"admin"},
Permissions: config.PermissionsList{View: true, Exec: true},
},
{
Name: "everyone",
MatchUsernames: []string{"guest", "admin"},
Permissions: config.PermissionsList{View: true, Exec: true},
AddToEveryAction: true,
},
}
cfg.Entities = []*config.EntityFile{
{Name: "printers", File: "printers.yaml"},
{Name: "servers", File: "servers.yaml", Acls: []string{"ops"}},
}
cfg.Actions = []*config.Action{
{
ID: "restart-server",
Title: "Restart {{ servers.name }}",
Shell: "echo restart",
Entity: "servers",
},
{
ID: "ping-printer",
Title: "Ping printer",
Shell: "echo ping",
},
}
cfg.Dashboards = []*config.DashboardComponent{
{
Title: "Infra",
Contents: []*config.DashboardComponent{
{
Title: "{{ servers.name }}",
Type: "fieldset",
Entity: "servers",
Contents: []*config.DashboardComponent{
{Title: "Restart {{ servers.name }}"},
},
},
},
},
}
cfg.Sanitize()
return cfg
}
func seedEntityAclTestEntities(t *testing.T) {
t.Helper()
entities.ClearEntitiesOfType("printers")
entities.ClearEntitiesOfType("servers")
t.Cleanup(func() {
entities.ClearEntitiesOfType("printers")
entities.ClearEntitiesOfType("servers")
})
entities.AddEntity("printers", "p1", map[string]any{"name": "lobby"})
entities.AddEntity("servers", "0", map[string]any{"name": "web01"})
}
func TestGetEntitiesOmitsRestrictedEntityTypes(t *testing.T) {
seedEntityAclTestEntities(t)
cfg := buildEntityAclTestConfig()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
api := newServer(ex)
guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
guest.BuildUserAcls(cfg)
defs := api.buildEntityDefinitionsResponse(guest, &apiv1.GetEntitiesRequest{}, entities.GetEntities())
titles := make([]string, 0, len(defs))
for _, def := range defs {
titles = append(titles, def.Title)
}
assert.Contains(t, titles, "printers")
assert.NotContains(t, titles, "servers")
}
func TestGetEntityNotFoundForRestrictedType(t *testing.T) {
seedEntityAclTestEntities(t)
cfg := buildEntityAclTestConfig()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
// Guest Init/API without login uses guest user with default ACLs from UserFromApiCall.
_, err := client.GetEntity(context.Background(), connect.NewRequest(&apiv1.GetEntityRequest{
Type: "servers",
UniqueKey: "0",
}))
require.Error(t, err)
assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err))
resp, err := client.GetEntity(context.Background(), connect.NewRequest(&apiv1.GetEntityRequest{
Type: "printers",
UniqueKey: "p1",
}))
require.NoError(t, err)
assert.Equal(t, "lobby", resp.Msg.Title)
}
func TestSearchHintsOmitRestrictedEntitiesAndEntityBoundActions(t *testing.T) {
seedEntityAclTestEntities(t)
cfg := buildEntityAclTestConfig()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
api := newServer(ex)
guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
guest.BuildUserAcls(cfg)
admin := &authpublic.AuthenticatedUser{Username: "admin"}
admin.BuildUserAcls(cfg)
guestHints := api.buildSearchHints(guest)
require.NotNil(t, guestHints)
guestEntityKeys := make([]string, 0)
for _, hint := range guestHints.Entities {
guestEntityKeys = append(guestEntityKeys, hint.Type+":"+hint.UniqueKey)
}
assert.Contains(t, guestEntityKeys, "printers:p1")
assert.NotContains(t, guestEntityKeys, "servers:0")
guestActionIDs := actionHintBindingIDs(guestHints.Actions)
require.NotEmpty(t, guestActionIDs)
assert.Contains(t, guestActionIDs, "ping-printer")
assert.NotContains(t, guestActionIDs, "restart")
adminHints := api.buildSearchHints(admin)
require.NotNil(t, adminHints)
adminEntityKeys := make([]string, 0)
for _, hint := range adminHints.Entities {
adminEntityKeys = append(adminEntityKeys, hint.Type+":"+hint.UniqueKey)
}
assert.Contains(t, adminEntityKeys, "servers:0")
assert.NotEmpty(t, adminHints.Actions)
}
func TestEntityFieldsetOmitsRestrictedEntityType(t *testing.T) {
seedEntityAclTestEntities(t)
cfg := buildEntityAclTestConfig()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
api := newServer(ex)
guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
guest.BuildUserAcls(cfg)
admin := &authpublic.AuthenticatedUser{Username: "admin"}
admin.BuildUserAcls(cfg)
guestRR := api.createDashboardRenderRequest(guest, "", "")
guestDB := renderDashboard(guestRR, "Infra")
require.NotNil(t, guestDB)
assert.Empty(t, guestDB.Contents, "restricted entity fieldsets must not leak instances")
adminRR := api.createDashboardRenderRequest(admin, "", "")
adminDB := renderDashboard(adminRR, "Infra")
require.NotNil(t, adminDB)
require.NotEmpty(t, adminDB.Contents)
}
func TestBuildChoicesEntityRespectsEntityACL(t *testing.T) {
seedEntityAclTestEntities(t)
cfg := buildEntityAclTestConfig()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
guest.BuildUserAcls(cfg)
admin := &authpublic.AuthenticatedUser{Username: "admin"}
admin.BuildUserAcls(cfg)
arg := config.ActionArgument{
Entity: "servers",
Choices: []config.ActionArgumentChoice{
{Title: "{{ servers.name }}", Value: "{{ servers.name }}"},
},
}
guestRR := &DashboardRenderRequest{AuthenticatedUser: guest, cfg: cfg, ex: ex}
assert.Empty(t, buildChoices(arg, guestRR))
adminRR := &DashboardRenderRequest{AuthenticatedUser: admin, cfg: cfg, ex: ex}
choices := buildChoices(arg, adminRR)
require.Len(t, choices, 1)
assert.Equal(t, "web01", choices[0].Value)
}

View File

@ -0,0 +1,152 @@
package api
import (
"fmt"
"strings"
"connectrpc.com/connect"
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"
)
// errUnlessStartEntityAccessAllowed enforces entity-type view ACL on the binding
// and rejects entity-backed argument values the user may not use.
func (api *oliveTinAPI) errUnlessStartEntityAccessAllowed(user *authpublic.AuthenticatedUser, binding *executor.ActionBinding, args map[string]string) error {
if err := api.errUnlessBindingEntityTypeAllowed(user, binding); err != nil {
return err
}
if binding == nil {
return nil
}
return api.errUnlessEntityArgumentsAllowed(user, binding.Action, args)
}
// errUnlessEntityArgumentsAllowed rejects starts that use entity-backed arguments
// the user may not view, or guessed values that are not in the allowed choice set.
func (api *oliveTinAPI) errUnlessEntityArgumentsAllowed(user *authpublic.AuthenticatedUser, action *config.Action, args map[string]string) error {
if action == nil {
return nil
}
for i := range action.Arguments {
arg := &action.Arguments[i]
if arg.Entity == "" {
continue
}
if err := api.errUnlessEntityArgumentAllowed(user, arg, args[arg.Name]); err != nil {
return err
}
}
return nil
}
func isEntityBackedArgument(arg *config.ActionArgument) bool {
return arg != nil && arg.Entity != "" && len(arg.Choices) == 1
}
func isMalformedEntityArgument(arg *config.ActionArgument) bool {
return arg != nil && arg.Entity != "" && len(arg.Choices) != 1
}
func (api *oliveTinAPI) errUnlessEntityArgumentAllowed(user *authpublic.AuthenticatedUser, arg *config.ActionArgument, value string) error {
if err := errUnlessEntityArgumentShapeAllowed(arg); err != nil {
return err
}
if !isEntityBackedArgument(arg) {
return nil
}
if !api.userCanViewEntityType(user, arg.Entity) {
return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
}
if err := errUnlessEntityArgumentValueAllowed(arg, value); err != nil {
return connect.NewError(connect.CodeInvalidArgument, err)
}
return nil
}
func errUnlessEntityArgumentShapeAllowed(arg *config.ActionArgument) error {
if !isMalformedEntityArgument(arg) {
return nil
}
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("argument %q with entity must define exactly one choice template", arg.Name))
}
func errUnlessEntityArgumentValueAllowed(arg *config.ActionArgument, value string) error {
if isMalformedEntityArgument(arg) {
return fmt.Errorf("argument %q with entity must define exactly one choice template", arg.Name)
}
if !isEntityBackedArgument(arg) {
return nil
}
value = strings.TrimSpace(value)
if value == "" {
return nil
}
if !entityArgumentValueAllowed(arg, value) {
return fmt.Errorf("argument %q is not a permitted entity value", arg.Name)
}
return nil
}
func entityArgumentValueAllowed(arg *config.ActionArgument, value string) bool {
allowed := entityArgumentAllowedValues(arg)
if strings.EqualFold(arg.Type, "checklist") {
return checklistEntityValuesAllowed(value, allowed)
}
_, ok := allowed[value]
return ok
}
func entityArgumentAllowedValues(arg *config.ActionArgument) map[string]struct{} {
allowed := make(map[string]struct{})
if arg == nil || len(arg.Choices) != 1 {
return allowed
}
for _, ent := range entities.GetEntityInstancesOrdered(arg.Entity) {
resolved := tpl.ParseTemplateOfActionBeforeExec(arg.Choices[0].Value, ent)
if resolved == "" {
continue
}
allowed[resolved] = struct{}{}
}
return allowed
}
func checklistEntityValuesAllowed(value string, allowed map[string]struct{}) bool {
parts := strings.Split(value, ",")
sawItem := false
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
sawItem = true
if _, ok := allowed[part]; !ok {
return false
}
}
return sawItem
}

View File

@ -0,0 +1,164 @@
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 buildEntityArgumentGuessConfig() *config.Config {
cfg := config.DefaultConfig()
cfg.DefaultPermissions.View = false
cfg.DefaultPermissions.Exec = false
cfg.AccessControlLists = []*config.AccessControlList{
{
Name: "ops",
MatchUsernames: []string{"admin"},
Permissions: config.PermissionsList{View: true, Exec: true},
},
{
Name: "everyone",
MatchUsernames: []string{"guest", "admin"},
Permissions: config.PermissionsList{View: true, Exec: true},
AddToEveryAction: true,
},
}
cfg.Entities = []*config.EntityFile{
{Name: "servers", File: "servers.yaml", Acls: []string{"ops"}},
}
cfg.Actions = []*config.Action{
{
ID: "reboot-server",
Title: "Reboot server",
Shell: "echo reboot '{{ target }}'",
Arguments: []config.ActionArgument{
{
Name: "target",
Title: "Server",
Entity: "servers",
Choices: []config.ActionArgumentChoice{
{Title: "{{ servers.name }}", Value: "{{ servers.name }}"},
},
},
},
},
}
cfg.Sanitize()
return cfg
}
func seedEntityArgumentGuessEntities(t *testing.T) {
t.Helper()
entities.ClearEntitiesOfType("servers")
t.Cleanup(func() {
entities.ClearEntitiesOfType("servers")
})
entities.AddEntity("servers", "0", map[string]any{"name": "web01"})
entities.AddEntity("servers", "1", map[string]any{"name": "db01"})
}
func TestStartActionRejectsGuessedRestrictedEntityArgument(t *testing.T) {
seedEntityArgumentGuessEntities(t)
cfg := buildEntityArgumentGuessConfig()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
_, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: "reboot-server",
Arguments: []*apiv1.StartActionArgument{
{Name: "target", Value: "web01"},
},
}))
require.Error(t, err)
assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
}
func TestStartActionRejectsUnknownEntityArgumentValue(t *testing.T) {
seedEntityArgumentGuessEntities(t)
cfg := buildEntityArgumentGuessConfig()
// Make servers unrestricted so guests can view the type but not invent values.
cfg.Entities[0].Acls = nil
cfg.Sanitize()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
_, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: "reboot-server",
Arguments: []*apiv1.StartActionArgument{
{Name: "target", Value: "not-a-real-server"},
},
}))
require.Error(t, err)
assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
}
func TestStartActionAllowsListedEntityArgumentValue(t *testing.T) {
seedEntityArgumentGuessEntities(t)
cfg := buildEntityArgumentGuessConfig()
cfg.Entities[0].Acls = nil
cfg.Sanitize()
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
resp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: "reboot-server",
Arguments: []*apiv1.StartActionArgument{
{Name: "target", Value: "web01"},
},
}))
require.NoError(t, err)
assert.NotEmpty(t, resp.Msg.ExecutionTrackingId)
}
func TestChecklistEntityValuesAllowedRejectsBlankOnlyInput(t *testing.T) {
allowed := map[string]struct{}{"web01": {}, "db01": {}}
assert.False(t, checklistEntityValuesAllowed(",,,", allowed))
assert.False(t, checklistEntityValuesAllowed(" , ", allowed))
assert.False(t, checklistEntityValuesAllowed("", allowed),
"all-blank checklist parts are rejected here; empty string is accepted by the caller separately")
assert.True(t, checklistEntityValuesAllowed("web01", allowed))
assert.True(t, checklistEntityValuesAllowed("web01, db01", allowed))
assert.False(t, checklistEntityValuesAllowed("web01, unknown", allowed))
}
func TestStartActionRejectsMalformedMultiChoiceEntityArgument(t *testing.T) {
seedEntityArgumentGuessEntities(t)
cfg := buildEntityArgumentGuessConfig()
// After sanitize, force an invalid entity+multi-choice shape that would
// previously skip ACL and fall through to static UI choices.
cfg.Actions[0].Arguments[0].Choices = []config.ActionArgumentChoice{
{Title: "{{ servers.name }}", Value: "{{ servers.name }}"},
{Title: "web01", Value: "web01"},
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
_, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: "reboot-server",
Arguments: []*apiv1.StartActionArgument{
{Name: "target", Value: "web01"},
},
}))
require.Error(t, err)
assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
}

View File

@ -0,0 +1,273 @@
package api
import (
"context"
"fmt"
"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 TestInitIncludesEntitySearchHints(t *testing.T) {
entities.ClearEntitiesOfType("server")
entities.ClearEntitiesOfType("database")
t.Cleanup(func() {
entities.ClearEntitiesOfType("server")
entities.ClearEntitiesOfType("database")
})
entities.AddEntity("server", "0", map[string]any{
"name": "web01",
"secret": "must-not-appear-in-search-hints",
})
entities.AddEntity("database", "db-1", map[string]any{
"title": "postgres",
})
cfg := config.DefaultConfig()
cfg.Features.HeaderSearch = true
cfg.Sanitize()
testExecutor := executor.DefaultExecutor(cfg)
testExecutor.RebuildActionMap()
testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor)
defer testServer.Close()
resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
require.NoError(t, err)
require.NotNil(t, resp.Msg.Features)
assert.True(t, resp.Msg.Features.HeaderSearch)
require.NotNil(t, resp.Msg.SearchHints)
byKey := map[string]*apiv1.EntitySearchHint{}
for _, hint := range resp.Msg.SearchHints.Entities {
byKey[hint.Type+":"+hint.UniqueKey] = hint
}
host, ok := byKey["server:0"]
require.True(t, ok, "expected server:0 search hint")
assert.Equal(t, "web01", host.Title)
assert.Equal(t, "server", host.Type)
assert.Equal(t, "0", host.UniqueKey)
db, ok := byKey["database:db-1"]
require.True(t, ok, "expected database:db-1 search hint")
assert.Equal(t, "postgres", db.Title)
}
func TestInitOmitsSearchHintsWhenLoginRequired(t *testing.T) {
entities.ClearEntitiesOfType("server")
t.Cleanup(func() {
entities.ClearEntitiesOfType("server")
})
entities.AddEntity("server", "0", map[string]any{"name": "web01"})
cfg := config.DefaultConfig()
cfg.AuthRequireGuestsToLogin = true
cfg.Features.HeaderSearch = true
cfg.Sanitize()
testExecutor := executor.DefaultExecutor(cfg)
testExecutor.RebuildActionMap()
testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor)
defer testServer.Close()
resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
require.NoError(t, err)
require.True(t, resp.Msg.LoginRequired)
assert.Nil(t, resp.Msg.SearchHints)
}
func TestInitOmitsSearchHintsWhenHeaderSearchDisabled(t *testing.T) {
entities.ClearEntitiesOfType("server")
t.Cleanup(func() {
entities.ClearEntitiesOfType("server")
})
entities.AddEntity("server", "0", map[string]any{"name": "web01"})
cfg := config.DefaultConfig()
cfg.Sanitize()
require.False(t, cfg.Features.HeaderSearch)
testExecutor := executor.DefaultExecutor(cfg)
testExecutor.RebuildActionMap()
testServer, client := getNewTestServerAndClientWithExecutor(cfg, testExecutor)
defer testServer.Close()
resp, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
require.NoError(t, err)
require.NotNil(t, resp.Msg.Features)
assert.False(t, resp.Msg.Features.HeaderSearch)
assert.Nil(t, resp.Msg.SearchHints)
}
func TestBuildSearchHintsRespectsActionACL(t *testing.T) {
cfg := config.DefaultConfig()
cfg.DefaultPermissions.View = false
cfg.DefaultPermissions.Exec = false
cfg.Actions = []*config.Action{
{ID: "public_action", Title: "Public Action", Shell: "echo public"},
{ID: "secret_action", Title: "Secret Action", Shell: "echo secret", Acls: []string{"admins"}},
}
cfg.AccessControlLists = []*config.AccessControlList{
{
Name: "everyone",
MatchUsernames: []string{"guest", "admin"},
AddToEveryAction: false,
Permissions: config.PermissionsList{View: true, Exec: true},
},
{
Name: "admins",
MatchUsernames: []string{"admin"},
Permissions: config.PermissionsList{View: true, Exec: true},
},
}
cfg.Actions[0].Acls = []string{"everyone"}
cfg.Sanitize()
testExecutor := executor.DefaultExecutor(cfg)
testExecutor.RebuildActionMap()
api := newServer(testExecutor)
guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
guest.BuildUserAcls(cfg)
admin := &authpublic.AuthenticatedUser{Username: "admin"}
admin.BuildUserAcls(cfg)
guestHints := api.buildSearchHints(guest)
require.NotNil(t, guestHints)
guestActionIDs := actionHintBindingIDs(guestHints.Actions)
assert.Contains(t, guestActionIDs, "public_action")
assert.NotContains(t, guestActionIDs, "secret_action")
adminHints := api.buildSearchHints(admin)
require.NotNil(t, adminHints)
adminActionIDs := actionHintBindingIDs(adminHints.Actions)
assert.Contains(t, adminActionIDs, "public_action")
assert.Contains(t, adminActionIDs, "secret_action")
}
func TestBuildSearchHintsOmitsHiddenActions(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
{ID: "visible", Title: "Visible", Shell: "echo visible"},
{ID: "hidden", Title: "Hidden", Shell: "echo hidden", Hidden: true},
}
cfg.Sanitize()
testExecutor := executor.DefaultExecutor(cfg)
testExecutor.RebuildActionMap()
api := newServer(testExecutor)
user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
user.BuildUserAcls(cfg)
hints := api.buildSearchHints(user)
require.NotNil(t, hints)
ids := actionHintBindingIDs(hints.Actions)
assert.Contains(t, ids, "visible")
assert.NotContains(t, ids, "hidden")
}
func TestBuildSearchHintsCapsActionsAndEntitiesPerType(t *testing.T) {
entities.ClearEntitiesOfType("cap_host")
entities.ClearEntitiesOfType("cap_container")
t.Cleanup(func() {
entities.ClearEntitiesOfType("cap_host")
entities.ClearEntitiesOfType("cap_container")
})
for i := 0; i < maxSearchHintEntitiesPerType+5; i++ {
entities.AddEntity("cap_host", fmt.Sprintf("%03d", i), map[string]any{"name": fmt.Sprintf("host-%03d", i)})
entities.AddEntity("cap_container", fmt.Sprintf("%03d", i), map[string]any{"name": fmt.Sprintf("ctr-%03d", i)})
}
cfg := config.DefaultConfig()
cfg.Entities = []*config.EntityFile{
{Name: "cap_host", File: "cap_host.yaml"},
{Name: "cap_container", File: "cap_container.yaml"},
}
cfg.Actions = make([]*config.Action, 0, maxSearchHintActions+5)
for i := 0; i < maxSearchHintActions+5; i++ {
cfg.Actions = append(cfg.Actions, &config.Action{
ID: fmt.Sprintf("action-%03d", i),
Title: fmt.Sprintf("Action %03d", i),
Shell: "echo",
})
}
cfg.Sanitize()
testExecutor := executor.DefaultExecutor(cfg)
testExecutor.RebuildActionMap()
api := newServer(testExecutor)
user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
user.BuildUserAcls(cfg)
hints := api.buildSearchHints(user)
require.NotNil(t, hints)
assert.Len(t, hints.Actions, maxSearchHintActions)
assert.Equal(t, maxSearchHintEntitiesPerType, countEntityHintsByType(hints.Entities, "cap_host"))
assert.Equal(t, maxSearchHintEntitiesPerType, countEntityHintsByType(hints.Entities, "cap_container"))
}
func countEntityHintsByType(hints []*apiv1.EntitySearchHint, entityType string) int {
count := 0
for _, hint := range hints {
if hint.Type == entityType {
count++
}
}
return count
}
func TestBuildSearchHintsPrefersNonEntityActions(t *testing.T) {
entities.ClearEntitiesOfType("host")
t.Cleanup(func() {
entities.ClearEntitiesOfType("host")
})
for i := 0; i < 10; i++ {
entities.AddEntity("host", fmt.Sprintf("%d", i), map[string]any{"name": fmt.Sprintf("host-%d", i)})
}
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
{ID: "plain", Title: "Plain Action", Shell: "echo plain"},
{Title: "Entity Action {{ name }}", Shell: "echo entity", Entity: "host"},
}
cfg.Sanitize()
testExecutor := executor.DefaultExecutor(cfg)
testExecutor.RebuildActionMap()
api := newServer(testExecutor)
user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
user.BuildUserAcls(cfg)
hints := api.buildSearchHints(user)
require.NotNil(t, hints)
require.NotEmpty(t, hints.Actions)
assert.Equal(t, "plain", hints.Actions[0].BindingId)
}
func actionHintBindingIDs(hints []*apiv1.ActionSearchHint) []string {
ids := make([]string, 0, len(hints))
for _, hint := range hints {
ids = append(ids, hint.BindingId)
}
return ids
}

View File

@ -0,0 +1,168 @@
package api
import (
"sort"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
acl "github.com/OliveTin/OliveTin/internal/acl"
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
"github.com/OliveTin/OliveTin/internal/entities"
executor "github.com/OliveTin/OliveTin/internal/executor"
"github.com/OliveTin/OliveTin/internal/tpl"
)
const (
maxSearchHintActions = 100
maxSearchHintEntitiesPerType = 50
)
// buildSearchHints returns lightweight search index data for Init.
// Dashboards are not included; clients index Init.root_dashboard_entries.
// Visibility matches action/entity view ACL; omitted entirely when login is required.
func (api *oliveTinAPI) buildSearchHints(user *authpublic.AuthenticatedUser) *apiv1.SearchHints {
return &apiv1.SearchHints{
Entities: api.buildEntitySearchHints(user),
Actions: api.buildActionSearchHints(user),
}
}
func (api *oliveTinAPI) buildEntitySearchHints(user *authpublic.AuthenticatedUser) []*apiv1.EntitySearchHint {
hints := entities.ListSearchHints()
out := make([]*apiv1.EntitySearchHint, 0, len(hints))
for _, hint := range hints {
if allowedHint := api.entitySearchHintIfAllowed(user, hint); allowedHint != nil {
out = append(out, allowedHint)
}
}
sortEntitySearchHints(out)
return capEntitySearchHintsPerType(out, maxSearchHintEntitiesPerType)
}
func (api *oliveTinAPI) entitySearchHintIfAllowed(user *authpublic.AuthenticatedUser, hint entities.SearchHint) *apiv1.EntitySearchHint {
if hint.UniqueKey == "" || hint.Type == "" {
return nil
}
if !api.userCanViewEntityType(user, hint.Type) {
return nil
}
return &apiv1.EntitySearchHint{
Title: hint.Title,
Type: hint.Type,
UniqueKey: hint.UniqueKey,
}
}
func sortEntitySearchHints(hints []*apiv1.EntitySearchHint) {
sort.SliceStable(hints, func(leftIndex, rightIndex int) bool {
if hints[leftIndex].Type != hints[rightIndex].Type {
return hints[leftIndex].Type < hints[rightIndex].Type
}
if hints[leftIndex].UniqueKey != hints[rightIndex].UniqueKey {
return hints[leftIndex].UniqueKey < hints[rightIndex].UniqueKey
}
return hints[leftIndex].Title < hints[rightIndex].Title
})
}
func capEntitySearchHintsPerType(hints []*apiv1.EntitySearchHint, perType int) []*apiv1.EntitySearchHint {
if perType < 1 || len(hints) == 0 {
return hints
}
counts := make(map[string]int)
out := make([]*apiv1.EntitySearchHint, 0, len(hints))
for _, hint := range hints {
if counts[hint.Type] >= perType {
continue
}
counts[hint.Type]++
out = append(out, hint)
}
return out
}
func (api *oliveTinAPI) buildActionSearchHints(user *authpublic.AuthenticatedUser) []*apiv1.ActionSearchHint {
candidates := api.collectViewableActionBindings(user)
sortActionSearchCandidates(candidates)
if len(candidates) > maxSearchHintActions {
candidates = candidates[:maxSearchHintActions]
}
out := make([]*apiv1.ActionSearchHint, 0, len(candidates))
for _, candidate := range candidates {
out = append(out, &apiv1.ActionSearchHint{
Title: candidate.title,
BindingId: candidate.bindingID,
})
}
return out
}
type actionSearchCandidate struct {
title string
bindingID string
hasEntity bool
}
func (api *oliveTinAPI) collectViewableActionBindings(user *authpublic.AuthenticatedUser) []actionSearchCandidate {
api.executor.MapActionBindingsLock.RLock()
defer api.executor.MapActionBindingsLock.RUnlock()
candidates := make([]actionSearchCandidate, 0)
for _, binding := range api.executor.MapActionBindings {
if candidate, ok := actionSearchCandidateFromBinding(api, user, binding); ok {
candidates = append(candidates, candidate)
}
}
return candidates
}
func actionSearchCandidateFromBinding(api *oliveTinAPI, user *authpublic.AuthenticatedUser, binding *executor.ActionBinding) (actionSearchCandidate, bool) {
if !isSearchableActionBinding(binding) {
return actionSearchCandidate{}, false
}
if !acl.IsAllowedView(api.cfg, user, binding.Action) {
return actionSearchCandidate{}, false
}
if !api.bindingEntityTypeAllowed(user, binding) {
return actionSearchCandidate{}, false
}
return actionSearchCandidate{
title: tpl.ParseTemplateOfActionBeforeExec(binding.Action.Title, binding.Entity),
bindingID: binding.ID,
hasEntity: binding.Entity != nil,
}, true
}
func isSearchableActionBinding(binding *executor.ActionBinding) bool {
return binding != nil && binding.Action != nil && binding.ID != "" && !binding.Action.Hidden
}
func sortActionSearchCandidates(candidates []actionSearchCandidate) {
sort.SliceStable(candidates, func(leftIndex, rightIndex int) bool {
if candidates[leftIndex].hasEntity != candidates[rightIndex].hasEntity {
return !candidates[leftIndex].hasEntity
}
if candidates[leftIndex].title != candidates[rightIndex].title {
return candidates[leftIndex].title < candidates[rightIndex].title
}
return candidates[leftIndex].bindingID < candidates[rightIndex].bindingID
})
}

View File

@ -1091,6 +1091,15 @@ func TestBuildChoicesExpandsChecklistEntityChoices(t *testing.T) {
entities.ClearEntitiesOfType("room") entities.ClearEntitiesOfType("room")
}) })
cfg := config.DefaultConfig()
cfg.Entities = []*config.EntityFile{
{Name: "room", File: "room.yaml"},
}
cfg.Sanitize()
user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
user.BuildUserAcls(cfg)
arg := config.ActionArgument{ arg := config.ActionArgument{
Type: "checklist", Type: "checklist",
Entity: "room", Entity: "room",
@ -1099,7 +1108,7 @@ func TestBuildChoicesExpandsChecklistEntityChoices(t *testing.T) {
}, },
} }
choices := buildChoices(arg) choices := buildChoices(arg, &DashboardRenderRequest{AuthenticatedUser: user, cfg: cfg})
require.Len(t, choices, 2) require.Len(t, choices, 2)
assert.Equal(t, "attic", choices[0].Value) assert.Equal(t, "attic", choices[0].Value)
assert.Equal(t, "attic", choices[0].Title) assert.Equal(t, "attic", choices[0].Title)

View File

@ -2,6 +2,7 @@ package api
import ( import (
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
acl "github.com/OliveTin/OliveTin/internal/acl"
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
entities "github.com/OliveTin/OliveTin/internal/entities" entities "github.com/OliveTin/OliveTin/internal/entities"
"github.com/OliveTin/OliveTin/internal/tpl" "github.com/OliveTin/OliveTin/internal/tpl"
@ -9,6 +10,10 @@ import (
) )
func buildEntityFieldsets(entityTitle string, tpl *config.DashboardComponent, rr *DashboardRenderRequest) []*apiv1.DashboardComponent { func buildEntityFieldsets(entityTitle string, tpl *config.DashboardComponent, rr *DashboardRenderRequest) []*apiv1.DashboardComponent {
if !acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, entityTitle)) {
return nil
}
ret := make([]*apiv1.DashboardComponent, 0) ret := make([]*apiv1.DashboardComponent, 0)
orderedEntities := entities.GetEntityInstancesOrdered(entityTitle) orderedEntities := entities.GetEntityInstancesOrdered(entityTitle)

View File

@ -26,6 +26,10 @@ func getEntityFromRequest(rr *DashboardRenderRequest) *entities.Entity {
return nil return nil
} }
if !acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, rr.EntityType)) {
return nil
}
entityInstances := entities.GetEntityInstances(rr.EntityType) entityInstances := entities.GetEntityInstances(rr.EntityType)
if entity, ok := entityInstances[rr.EntityKey]; ok { if entity, ok := entityInstances[rr.EntityKey]; ok {
return entity return entity
@ -157,6 +161,11 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
continue continue
} }
if binding.Entity != nil && binding.Action.Entity != "" &&
!acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, binding.Action.Entity)) {
continue
}
action := buildAction(binding, rr) action := buildAction(binding, rr)
if action == nil { if action == nil {
continue continue

View File

@ -112,6 +112,7 @@ type EntityFile struct {
Icon string `koanf:"icon"` Icon string `koanf:"icon"`
SourceFile string `koanf:"-"` SourceFile string `koanf:"-"`
Properties []EntityProperty `koanf:"properties"` Properties []EntityProperty `koanf:"properties"`
Acls []string `koanf:"acls"`
} }
// EntityProperty defines a column shown when listing entity instances in the UI. // EntityProperty defines a column shown when listing entity instances in the UI.
@ -145,6 +146,11 @@ type ConfigurationPolicy struct {
ShowVersionNumber bool `koanf:"showVersionNumber"` ShowVersionNumber bool `koanf:"showVersionNumber"`
} }
// FeaturesConfig holds global opt-in feature flags. New flags default to false.
type FeaturesConfig struct {
HeaderSearch bool `koanf:"headerSearch"`
}
type PrometheusConfig struct { type PrometheusConfig struct {
Enabled bool `koanf:"enabled"` Enabled bool `koanf:"enabled"`
DefaultGoMetrics bool `koanf:"defaultGoMetrics"` DefaultGoMetrics bool `koanf:"defaultGoMetrics"`
@ -228,6 +234,7 @@ type Config struct {
ShowFooter bool `koanf:"showFooter"` ShowFooter bool `koanf:"showFooter"`
UseSingleHTTPFrontend bool `koanf:"useSingleHTTPFrontend"` UseSingleHTTPFrontend bool `koanf:"useSingleHTTPFrontend"`
ThemeCacheDisabled bool `koanf:"themeCacheDisabled"` ThemeCacheDisabled bool `koanf:"themeCacheDisabled"`
Features FeaturesConfig `koanf:"features"`
} }
type AuthLocalUsersConfig struct { type AuthLocalUsersConfig struct {

View File

@ -0,0 +1,40 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestValidateEntityArgumentChoicesForArgument(t *testing.T) {
assert.NoError(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
Name: "target",
Entity: "servers",
Choices: []ActionArgumentChoice{
{Value: "{{ servers.name }}"},
},
}))
assert.Error(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
Name: "target",
Entity: "servers",
Choices: []ActionArgumentChoice{
{Value: "{{ servers.name }}"},
{Value: "web01"},
},
}))
assert.Error(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
Name: "target",
Entity: "servers",
Choices: nil,
}))
assert.NoError(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
Name: "plain",
Choices: []ActionArgumentChoice{
{Value: "a"},
{Value: "b"},
},
}))
}

View File

@ -40,6 +40,10 @@ func (cfg *Config) Sanitize() {
if err := cfg.validateChecklistChoiceValues(); err != nil { if err := cfg.validateChecklistChoiceValues(); err != nil {
log.Fatalf("%v", err) log.Fatalf("%v", err)
} }
if err := cfg.validateEntityArgumentChoices(); err != nil {
log.Fatalf("%v", err)
}
} }
func (cfg *Config) validateReservedActionArgumentNames() error { func (cfg *Config) validateReservedActionArgumentNames() error {
@ -108,6 +112,42 @@ func validateChecklistChoicesForArgument(actionTitle string, arg ActionArgument)
return nil return nil
} }
func (cfg *Config) validateEntityArgumentChoices() error {
for _, action := range cfg.Actions {
if err := action.validateEntityArgumentChoices(); err != nil {
return err
}
}
return nil
}
func (action *Action) validateEntityArgumentChoices() error {
if action == nil {
return nil
}
for _, arg := range action.Arguments {
if err := validateEntityArgumentChoicesForArgument(action.Title, arg); err != nil {
return err
}
}
return nil
}
func validateEntityArgumentChoicesForArgument(actionTitle string, arg ActionArgument) error {
if arg.Entity == "" || len(arg.Choices) == 1 {
return nil
}
return fmt.Errorf(
`action %q argument %q with entity must define exactly one choice template`,
actionTitle,
arg.Name,
)
}
func (cfg *Config) sanitizeDashboardsForInlineActions() { func (cfg *Config) sanitizeDashboardsForInlineActions() {
for _, dashboard := range cfg.Dashboards { for _, dashboard := range cfg.Dashboards {
cfg.sanitizeDashboardComponentForInlineActions(dashboard) cfg.sanitizeDashboardComponentForInlineActions(dashboard)

View File

@ -28,10 +28,12 @@ func Rebuild(cfg *config.Config, extra ...configissues.Issue) {
collected := make([]configissues.Issue, 0) collected := make([]configissues.Issue, 0)
collected = append(collected, configissues.CopySticky()...) collected = append(collected, configissues.CopySticky()...)
collected = append(collected, collectActionGroupIssues(cfg)...) collected = append(collected, collectActionGroupIssues(cfg)...)
collected = append(collected, collectAclReferenceIssues(cfg)...)
collected = append(collected, collectArgumentIssues(cfg)...) collected = append(collected, collectArgumentIssues(cfg)...)
collected = append(collected, collectIncludeIssues(cfg)...) collected = append(collected, collectIncludeIssues(cfg)...)
collected = append(collected, collectTemplateParseIssues(cfg)...) collected = append(collected, collectTemplateParseIssues(cfg)...)
collected = append(collected, collectEntityFileIssues(cfg)...) collected = append(collected, collectEntityFileIssues(cfg)...)
collected = append(collected, collectOrphanEntityTypeIssues(cfg)...)
collected = append(collected, collectEntityEmptyIssues(cfg)...) collected = append(collected, collectEntityEmptyIssues(cfg)...)
collected = append(collected, collectCronIssues(cfg)...) collected = append(collected, collectCronIssues(cfg)...)
collected = append(collected, collectWatcherPathIssues(cfg)...) collected = append(collected, collectWatcherPathIssues(cfg)...)
@ -80,6 +82,55 @@ func actionIssue(action *config.Action, severity, code, message, source, argName
} }
} }
func collectAclReferenceIssues(cfg *config.Config) []configissues.Issue {
out := make([]configissues.Issue, 0)
out = append(out, collectActionAclIssues(cfg)...)
out = append(out, collectEntityAclIssues(cfg)...)
return out
}
func collectActionAclIssues(cfg *config.Config) []configissues.Issue {
out := make([]configissues.Issue, 0)
for _, action := range cfg.Actions {
if action == nil {
continue
}
for _, aclName := range action.Acls {
out = append(out, unknownAclIssue(cfg, aclName, action.ID, action.Title, action.SourceFile)...)
}
}
return out
}
func collectEntityAclIssues(cfg *config.Config) []configissues.Issue {
out := make([]configissues.Issue, 0)
for _, entityFile := range cfg.Entities {
if entityFile == nil {
continue
}
for _, aclName := range entityFile.Acls {
out = append(out, unknownAclIssue(cfg, aclName, "", entityFile.Name, entityFile.SourceFile)...)
}
}
return out
}
func unknownAclIssue(cfg *config.Config, aclName, actionID, title, configFile string) []configissues.Issue {
if cfg.FindAcl(aclName) != nil {
return nil
}
return []configissues.Issue{{
Severity: configissues.SeverityError,
Code: configissues.CodeAclUnknown,
Message: fmt.Sprintf("References unknown ACL %q", aclName),
ActionID: actionID,
ActionTitle: title,
Source: aclName,
ConfigFile: configFile,
}}
}
func collectArgumentIssues(cfg *config.Config) []configissues.Issue { func collectArgumentIssues(cfg *config.Config) []configissues.Issue {
out := make([]configissues.Issue, 0) out := make([]configissues.Issue, 0)
for _, action := range cfg.Actions { for _, action := range cfg.Actions {
@ -94,13 +145,13 @@ func collectArgumentIssues(cfg *config.Config) []configissues.Issue {
} }
func argumentIssues(action *config.Action, arg *config.ActionArgument) []configissues.Issue { func argumentIssues(action *config.Action, arg *config.ActionArgument) []configissues.Issue {
if arg.Type != "checklist" { out := make([]configissues.Issue, 0)
return nil out = append(out, entityArgumentChoicesIssue(action, arg)...)
if arg.Type == "checklist" {
out = append(out, checklistNoChoicesIssue(action, arg)...)
} }
out := make([]configissues.Issue, 0)
out = append(out, checklistNoChoicesIssue(action, arg)...)
out = append(out, checklistEntityChoicesIssue(action, arg)...)
return out return out
} }
@ -113,13 +164,13 @@ func checklistNoChoicesIssue(action *config.Action, arg *config.ActionArgument)
"Checklist argument has no choices defined", "", arg.Name)} "Checklist argument has no choices defined", "", arg.Name)}
} }
func checklistEntityChoicesIssue(action *config.Action, arg *config.ActionArgument) []configissues.Issue { func entityArgumentChoicesIssue(action *config.Action, arg *config.ActionArgument) []configissues.Issue {
if arg.Entity == "" || len(arg.Choices) <= 1 { if arg.Entity == "" || len(arg.Choices) == 1 {
return nil return nil
} }
return []configissues.Issue{actionIssue(action, configissues.SeverityWarning, configissues.CodeChecklistEntityChoices, return []configissues.Issue{actionIssue(action, configissues.SeverityError, configissues.CodeEntityArgumentChoices,
"Checklist argument with entity should define exactly one choice template", arg.Entity, arg.Name)} "Arguments with entity must define exactly one choice template", arg.Entity, arg.Name)}
} }
func collectIncludeIssues(cfg *config.Config) []configissues.Issue { func collectIncludeIssues(cfg *config.Config) []configissues.Issue {
@ -215,6 +266,42 @@ func collectEntityFileIssues(cfg *config.Config) []configissues.Issue {
return out return out
} }
func collectOrphanEntityTypeIssues(cfg *config.Config) []configissues.Issue {
configured := configuredEntityTypeNames(cfg)
out := make([]configissues.Issue, 0)
for entityType := range entities.GetEntities() {
if configured[entityType] {
continue
}
out = append(out, configissues.Issue{
Severity: configissues.SeverityWarning,
Code: configissues.CodeEntityTypeUnconfigured,
Message: fmt.Sprintf("Entity type %q is loaded but has no matching entities entry in configuration", entityType),
Source: entityType,
})
}
return out
}
func configuredEntityTypeNames(cfg *config.Config) map[string]bool {
configured := make(map[string]bool)
if cfg == nil {
return configured
}
for _, entityFile := range cfg.Entities {
if entityFile == nil || entityFile.Name == "" {
continue
}
configured[entityFile.Name] = true
}
return configured
}
func entityFileIssuesFor(ef *config.EntityFile, baseDir string) []configissues.Issue { func entityFileIssuesFor(ef *config.EntityFile, baseDir string) []configissues.Issue {
if ef == nil || ef.File == "" { if ef == nil || ef.File == "" {
return nil return nil

View File

@ -191,6 +191,96 @@ func TestStickyEnvUnsetSurvivesRebuild(t *testing.T) {
assert.True(t, hasCode(configissues.List(), configissues.CodeEnvUnset)) assert.True(t, hasCode(configissues.List(), configissues.CodeEnvUnset))
} }
func TestRebuildWarnsForEntityTypeWithoutConfigEntry(t *testing.T) {
entities.ClearEntitiesOfType("orphan_type")
t.Cleanup(func() {
entities.ClearEntitiesOfType("orphan_type")
})
entities.AddEntity("orphan_type", "0", map[string]any{"name": "lonely"})
configissues.BeginConfigLoad()
cfg := config.DefaultConfig()
cfg.Entities = nil
configcheck.Rebuild(cfg)
issues := configissues.List()
require.True(t, hasCode(issues, configissues.CodeEntityTypeUnconfigured))
found := false
for _, issue := range issues {
if issue.Code == configissues.CodeEntityTypeUnconfigured {
assert.Equal(t, configissues.SeverityWarning, issue.Severity)
assert.Equal(t, "orphan_type", issue.Source)
found = true
}
}
assert.True(t, found)
}
func TestRebuildEntityArgumentChoices(t *testing.T) {
configissues.BeginConfigLoad()
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
{
Title: "Reboot",
ID: "reboot",
Arguments: []config.ActionArgument{
{
Name: "target",
Entity: "servers",
Choices: []config.ActionArgumentChoice{
{Value: "{{ servers.name }}"},
{Value: "web01"},
},
},
},
},
}
configcheck.Rebuild(cfg)
issues := configissues.List()
require.True(t, hasCode(issues, configissues.CodeEntityArgumentChoices))
for _, issue := range issues {
if issue.Code == configissues.CodeEntityArgumentChoices {
assert.Equal(t, configissues.SeverityError, issue.Severity)
assert.Equal(t, "target", issue.ArgumentName)
}
}
}
func TestRebuildUnknownAclReferences(t *testing.T) {
configissues.BeginConfigLoad()
cfg := config.DefaultConfig()
cfg.AccessControlLists = []*config.AccessControlList{
{Name: "ops", MatchUsernames: []string{"admin"}, Permissions: config.PermissionsList{View: true}},
}
cfg.Actions = []*config.Action{
{Title: "Restart", ID: "restart", Acls: []string{"ops"}},
{Title: "Secret", ID: "secret", Acls: []string{"missing-acl"}},
}
cfg.Entities = []*config.EntityFile{
{Name: "printers", File: "printers.yaml"},
{Name: "servers", File: "servers.yaml", Acls: []string{"missing-entity-acl"}},
}
configcheck.Rebuild(cfg)
issues := configissues.List()
require.True(t, hasCode(issues, configissues.CodeAclUnknown))
unknownSources := make([]string, 0)
for _, issue := range issues {
if issue.Code == configissues.CodeAclUnknown {
assert.Equal(t, configissues.SeverityError, issue.Severity)
unknownSources = append(unknownSources, issue.Source)
}
}
assert.Contains(t, unknownSources, "missing-acl")
assert.Contains(t, unknownSources, "missing-entity-acl")
assert.NotContains(t, unknownSources, "ops")
}
func hasCode(issues []configissues.Issue, code string) bool { func hasCode(issues []configissues.Issue, code string) bool {
for _, issue := range issues { for _, issue := range issues {
if issue.Code == code { if issue.Code == code {

View File

@ -8,16 +8,18 @@ const (
CodeActionGroupUnenforced = "action_group_unenforced" CodeActionGroupUnenforced = "action_group_unenforced"
CodeArgTypeUnset = "arg_type_unset" CodeArgTypeUnset = "arg_type_unset"
CodeChecklistNoChoices = "checklist_no_choices" CodeChecklistNoChoices = "checklist_no_choices"
CodeChecklistEntityChoices = "checklist_entity_choices" CodeEntityArgumentChoices = "entity_argument_choices"
CodeEnvUnset = "env_unset" CodeEnvUnset = "env_unset"
CodeIncludeMissing = "include_missing" CodeIncludeMissing = "include_missing"
CodeTemplateParse = "template_parse" CodeTemplateParse = "template_parse"
CodeArgDefaultInvalid = "arg_default_invalid" CodeArgDefaultInvalid = "arg_default_invalid"
CodeEntityFile = "entity_file" CodeEntityFile = "entity_file"
CodeEntityEmpty = "entity_empty" CodeEntityEmpty = "entity_empty"
CodeEntityTypeUnconfigured = "entity_type_unconfigured"
CodeCronInvalid = "cron_invalid" CodeCronInvalid = "cron_invalid"
CodeCronEntityBinding = "cron_entity_binding" CodeCronEntityBinding = "cron_entity_binding"
CodeWatcherPath = "watcher_path" CodeWatcherPath = "watcher_path"
CodeAclUnknown = "acl_unknown"
) )
// Issue is a configuration warning or error surfaced on Diagnostics. // Issue is a configuration warning or error surfaced on Diagnostics.

View File

@ -0,0 +1,29 @@
package entities
type SearchHint struct {
Title string
Type string
UniqueKey string
}
func ListSearchHints() []SearchHint {
rwmutex.RLock()
defer rwmutex.RUnlock()
hints := make([]SearchHint, 0)
for entityType, instances := range entities {
for _, entity := range instances {
if entity == nil {
continue
}
hints = append(hints, SearchHint{
Title: entity.Title,
Type: entityType,
UniqueKey: entity.UniqueKey,
})
}
}
return hints
}

View File

@ -0,0 +1,42 @@
package entities
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestListSearchHintsReturnsLightweightIdentities(t *testing.T) {
ClearEntitiesOfType("search_hint_host")
ClearEntitiesOfType("search_hint_app")
t.Cleanup(func() {
ClearEntitiesOfType("search_hint_host")
ClearEntitiesOfType("search_hint_app")
})
AddEntity("search_hint_host", "0", map[string]any{
"name": "web01",
"secret": "should-not-be-copied-by-search-hints",
"hostname": "192.168.1.10",
})
AddEntity("search_hint_app", "app-1", map[string]any{
"title": "Frontend",
})
hints := ListSearchHints()
byKey := map[string]SearchHint{}
for _, hint := range hints {
byKey[hint.Type+":"+hint.UniqueKey] = hint
}
host, hostFound := byKey["search_hint_host:0"]
require.True(t, hostFound)
assert.Equal(t, "web01", host.Title)
assert.Equal(t, "search_hint_host", host.Type)
assert.Equal(t, "0", host.UniqueKey)
app, appFound := byKey["search_hint_app:app-1"]
require.True(t, appFound)
assert.Equal(t, "Frontend", app.Title)
}

View File

@ -300,6 +300,10 @@ func typecheckChoice(value string, arg *config.ActionArgument) error {
} }
func typecheckChoiceEntity(value string, arg *config.ActionArgument) error { func typecheckChoiceEntity(value string, arg *config.ActionArgument) error {
if len(arg.Choices) != 1 {
return fmt.Errorf("entity-backed argument must define exactly one choice template")
}
templateChoice := arg.Choices[0].Value templateChoice := arg.Choices[0].Value
for _, ent := range entities.GetEntityInstances(arg.Entity) { for _, ent := range entities.GetEntityInstances(arg.Entity) {
@ -613,7 +617,7 @@ func mangleChoiceSegment(arg *config.ActionArgument, value string, actionTitle s
} }
func mangleChoiceSegmentEntity(arg *config.ActionArgument, value string, actionTitle string) (string, bool) { func mangleChoiceSegmentEntity(arg *config.ActionArgument, value string, actionTitle string) (string, bool) {
if arg.Entity == "" || len(arg.Choices) == 0 { if arg.Entity == "" || len(arg.Choices) != 1 {
return value, false return value, false
} }

88
specs/entity-acls.md Normal file
View File

@ -0,0 +1,88 @@
# Spec: Entity access control
This spec describes how OliveTin restricts which entity types a user may see, and how that interacts with entity-related actions.
---
## 1. Scope
Access control applies at the **entity type** level (each configured entity definition), not per instance.
- Instances remain data loaded from entity files.
- Only the ability to **view** an entity type is consulted for these rules.
- Separate action permissions (view, execute, logs, kill) continue to govern actions themselves.
---
## 2. Configuration
Each entity definition may list zero or more named access-control entries.
- If the list is omitted or empty, the entity type is **unrestricted**: any user who may use the dashboard UI can see that type (same rule as root dashboards with no access-control list).
- If one or more named entries are listed, access is an allow list: a matching entry that grants view, otherwise the installation’s default view permission.
- Marking an access-control entry as applying to every action does not apply to entity types. An entry must be listed on the entity definition to restrict it.
---
## 3. Listing and details
When listing entity types and instances:
- Types the user cannot view are omitted from the list. The response does not advertise that those types exist.
- A request for a single entity whose type the user cannot view is treated as not found (the type is not confirmed to exist).
The Entities navigation page remains available; it simply shows fewer (or no) types when some are restricted.
---
## 4. Search hints
Client search hints for entities include only instances of types the user may view.
Entity-bound action hints (actions generated per entity instance) appear only when the user may view both the action and the entity type. Action view alone is not enough if the entity type is restricted.
Search hints are omitted from the initial client bootstrap when guests must log in, when header search is disabled for the installation, and the header search control is not shown until login is no longer required and header search is enabled.
Hints are capped at 100 actions and 50 entity instances **per entity type** per bootstrap response. The client applies the same caps when indexing.
Dashboards are not included in search hints. Clients build the dashboard search index from the bootstrap root dashboard entries (already filtered by dashboard access control).
Entity types with no access-control list remain unrestricted for search and listing.
---
## 5. Entity-related actions
There are two shapes of related actions on an entity details page:
1. Actions bound to the entity type (one binding per instance).
2. Unbound actions that prefill arguments from the entity.
Rules:
- Opening the entity details page requires view on that entity type.
- Seeing or running a related action still requires the action’s own permissions.
- Entity type access does not replace action permissions.
---
## 6. Dashboards and argument forms
When a dashboard expands an entity fieldset, instances of types the user cannot view are not rendered.
When an action argument draws choices from an entity type, it must define **exactly one** choice template and name the entity type. OliveTin expands that template per instance. Only instances of types the user may view are included. Users who can view the action but not the entity type must not learn instance names from the argument form.
Arguments that name an entity type with zero or multiple choice templates are invalid configuration: startup/reload rejects them, Diagnostics reports an error, the argument form shows no choices, and start/validate requests are rejected.
Starting or validating an action rejects entity-backed argument values when:
- The user may not view that entity type (permission denied), or
- The value is not one of the entity-expanded choice values for that argument (invalid argument).
Guessing an instance name must not bypass entity type access control.
---
## 7. Compatibility
Existing entity definitions without access-control lists stay unrestricted. Setting the default view permission to false alone does not hide unrestricted entity types; operators must list access-control entries on entity definitions to lock them down.

66
specs/feature-flags.md Normal file
View File

@ -0,0 +1,66 @@
# Spec: Feature flags
This spec describes how OliveTin opts into unfinished or optional product features for the whole installation.
---
## 1. Scope
Feature flags are **global** settings. They apply to every user and are not overridden by access control lists or policy.
They are distinct from policy options such as diagnostics or log list visibility, which may differ per user.
All feature-flagged functionality is **alpha / experimental**. Flags default to off. Private security reports for vulnerabilities that affect enabled experimental functionality are accepted under the project security policy.
---
## 2. Configuration
Operators configure flags in a dedicated section of the installation configuration.
- Each flag is a boolean.
- Omitted flags are **false**.
- Enabling a feature requires setting it to true explicitly.
Unknown keys in that section are ignored by the configuration loader (same as other unknown config keys).
---
## 3. Bootstrap projection
Every client bootstrap response includes the current feature flag values.
Clients must not assume a missing flag means enabled. Treat absent or false as off.
---
## 4. Server and client behavior
When a feature is off:
- The server skips work that exists only to support that feature (for example, building search hints when header search is off).
- The web UI hides controls and does not index or call APIs that exist only for that feature.
When a feature is on, normal access control still applies to the data and actions the feature exposes. Operators who enable a flag accept that the surface is experimental.
---
## 5. Header search
A header search flag controls the header search control and bootstrap search hints.
- Default: false (alpha / experimental).
- When false: bootstrap omits search hints; the client does not show header search or populate the search index.
- When true: bootstrap includes access-control-filtered search hints (unless guests must log in); the client shows header search after login is satisfied.
---
## 6. Adding a new flag
To add a flag:
1. Add a boolean setting in the feature-flags configuration section (default false).
2. Expose the same setting on every client bootstrap response.
3. Gate server work and UI behind the flag.
4. Document the flag as alpha / experimental and update this spec’s feature list.
5. Keep the security policy aligned: private reports remain accepted for enabled experimental functionality until the feature graduates (flag removed or stable default-on).