42 Commits

Author SHA1 Message Date
62307e8c65 publishing with intentional architecture
Some checks failed
Publish Images / Resolve Publish Metadata (push) Successful in 18s
Publish Images / Publish web (arm64) (push) Successful in 7m16s
CI / Rustfmt (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Security Advisory Checks (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Cargo Audit & Deny (push) Has been cancelled
CI / Web Blocking Checks (push) Has been cancelled
CI / Security Blocking Checks (push) Has been cancelled
CI / Web Advisory Checks (push) Has been cancelled
Publish Images / Publish agent (amd64) (push) Has been cancelled
Publish Images / Publish api (amd64) (push) Has been cancelled
Publish Images / Publish executor (amd64) (push) Has been cancelled
Publish Images / Publish notifier (amd64) (push) Has been cancelled
Publish Images / Publish agent (arm64) (push) Has been cancelled
Publish Images / Publish api (arm64) (push) Has been cancelled
Publish Images / Publish executor (arm64) (push) Has been cancelled
Publish Images / Publish notifier (arm64) (push) Has been cancelled
Publish Images / Publish web (amd64) (push) Has been cancelled
Publish Images / Build Rust Bundles (amd64) (push) Has started running
Publish Images / Publish manifest attune-agent (push) Has been cancelled
Publish Images / Publish manifest attune-api (push) Has been cancelled
Publish Images / Publish manifest attune-executor (push) Has been cancelled
Publish Images / Publish manifest attune-notifier (push) Has been cancelled
Publish Images / Build Rust Bundles (arm64) (push) Has been cancelled
Publish Images / Publish manifest attune-web (push) Has been cancelled
2026-03-25 01:10:10 -05:00
2ebb03b868 first pass at access control setup 2026-03-24 14:45:07 -05:00
af5175b96a removing no-longer-used dockerfiles.
Some checks failed
CI / Cargo Audit & Deny (push) Successful in 1m10s
CI / Security Blocking Checks (push) Successful in 10s
CI / Web Advisory Checks (push) Successful in 1m13s
CI / Clippy (push) Failing after 2m50s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 1s
CI / Security Advisory Checks (push) Successful in 1m24s
Publish Images And Chart / Publish init-packs (push) Failing after 12s
CI / Rustfmt (push) Successful in 4m22s
Publish Images And Chart / Publish web (push) Successful in 45s
Publish Images And Chart / Publish worker (push) Failing after 54s
Publish Images And Chart / Publish agent (push) Successful in 4m14s
CI / Web Blocking Checks (push) Successful in 9m31s
CI / Tests (push) Successful in 9m41s
Publish Images And Chart / Publish migrations (push) Failing after 13s
Publish Images And Chart / Publish sensor (push) Failing after 12s
Publish Images And Chart / Publish init-user (push) Failing after 2m3s
Publish Images And Chart / Publish api (push) Successful in 8m55s
Publish Images And Chart / Publish notifier (push) Successful in 8m53s
Publish Images And Chart / Publish executor (push) Successful in 1h16m29s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
2026-03-23 13:05:53 -05:00
8af8c1af9c first iteration of agent-style worker and sensor containers. 2026-03-23 12:49:15 -05:00
d4c6240485 agent workers 2026-03-21 10:05:02 -05:00
4d5a3b1bf5 agent-style workers 2026-03-21 08:27:20 -05:00
8ba7e3bb84 [wip] universal workers 2026-03-21 07:32:11 -05:00
0782675a2b purging unused Dockerfiles 2026-03-20 21:21:44 -05:00
5a18c73572 trying to make the pipeline builds work, desperately. 2026-03-20 20:15:44 -05:00
1c16f65476 addressing configuration dependency issues
Some checks failed
CI / Rustfmt (push) Successful in 59s
CI / Web Blocking Checks (push) Has been cancelled
CI / Security Blocking Checks (push) Has been cancelled
CI / Web Advisory Checks (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Security Advisory Checks (push) Has been cancelled
CI / Cargo Audit & Deny (push) Has been cancelled
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 3s
Publish Images And Chart / Publish init-packs (push) Successful in 47s
Publish Images And Chart / Publish sensor (push) Failing after 23s
Publish Images And Chart / Publish init-user (push) Successful in 1m51s
Publish Images And Chart / Publish migrations (push) Successful in 1m57s
Publish Images And Chart / Publish web (push) Successful in 57s
Publish Images And Chart / Publish api (push) Failing after 48s
Publish Images And Chart / Publish worker (push) Failing after 1m23s
Publish Images And Chart / Publish executor (push) Failing after 1m9s
Publish Images And Chart / Publish notifier (push) Failing after 1h44m16s
Publish Images And Chart / Publish Helm Chart (push) Has been cancelled
2026-03-20 19:50:44 -05:00
ae8029f9c4 patching npm audit finding
Some checks failed
CI / Tests (push) Has been cancelled
CI / Rustfmt (push) Has been cancelled
CI / Cargo Audit & Deny (push) Has been cancelled
CI / Web Blocking Checks (push) Has been cancelled
CI / Security Blocking Checks (push) Has been cancelled
CI / Web Advisory Checks (push) Has been cancelled
CI / Security Advisory Checks (push) Has been cancelled
CI / Clippy (push) Has been cancelled
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 2s
Publish Images And Chart / Publish init-user (push) Failing after 25s
Publish Images And Chart / Publish sensor (push) Failing after 22s
Publish Images And Chart / Publish migrations (push) Failing after 1m2s
Publish Images And Chart / Publish web (push) Failing after 50s
Publish Images And Chart / Publish worker (push) Failing after 50s
Publish Images And Chart / Publish executor (push) Has been cancelled
Publish Images And Chart / Publish api (push) Has been cancelled
Publish Images And Chart / Publish notifier (push) Has been cancelled
Publish Images And Chart / Publish init-packs (push) Failing after 1m21s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
2026-03-20 17:06:32 -05:00
882ba0da84 attempting more pipeline changes for local cluster registries 2026-03-20 17:04:57 -05:00
ee4fc31b9d attempting more pipeline changes for local cluster registries
Some checks failed
CI / Rustfmt (push) Successful in 57s
CI / Web Blocking Checks (push) Has been cancelled
CI / Security Blocking Checks (push) Has been cancelled
CI / Web Advisory Checks (push) Has been cancelled
CI / Security Advisory Checks (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Cargo Audit & Deny (push) Has been cancelled
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 1s
Publish Images And Chart / Publish init-packs (push) Failing after 14s
Publish Images And Chart / Publish migrations (push) Failing after 17s
Publish Images And Chart / Publish init-user (push) Failing after 35s
Publish Images And Chart / Publish sensor (push) Failing after 17s
Publish Images And Chart / Publish api (push) Failing after 15s
Publish Images And Chart / Publish web (push) Failing after 39s
Publish Images And Chart / Publish worker (push) Failing after 40s
Publish Images And Chart / Publish executor (push) Failing after 16s
Publish Images And Chart / Publish notifier (push) Failing after 38s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
2026-03-20 16:59:01 -05:00
c791495572 attempting more pipeline changes for local cluster registries
Some checks failed
CI / Cargo Audit & Deny (push) Has been cancelled
CI / Web Blocking Checks (push) Has been cancelled
CI / Security Blocking Checks (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Rustfmt (push) Has been cancelled
CI / Web Advisory Checks (push) Has been cancelled
CI / Security Advisory Checks (push) Has been cancelled
CI / Tests (push) Has been cancelled
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 2s
Publish Images And Chart / Publish Helm Chart (push) Blocked by required conditions
Publish Images And Chart / Publish notifier (push) Waiting to run
Publish Images And Chart / Publish init-packs (push) Failing after 1m19s
Publish Images And Chart / Publish init-user (push) Failing after 1m7s
Publish Images And Chart / Publish migrations (push) Failing after 1m7s
Publish Images And Chart / Publish sensor (push) Failing after 45s
Publish Images And Chart / Publish web (push) Failing after 3m28s
Publish Images And Chart / Publish worker (push) Failing after 48s
Publish Images And Chart / Publish api (push) Has been cancelled
Publish Images And Chart / Publish executor (push) Has been cancelled
2026-03-20 16:48:41 -05:00
35182ccb28 attempting more pipeline changes for local cluster registries
Some checks failed
CI / Rustfmt (push) Successful in 54s
CI / Security Advisory Checks (push) Waiting to run
CI / Web Blocking Checks (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Security Blocking Checks (push) Has been cancelled
CI / Cargo Audit & Deny (push) Has been cancelled
CI / Web Advisory Checks (push) Has been cancelled
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 5s
Publish Images And Chart / Publish init-packs (push) Has started running
Publish Images And Chart / Publish init-user (push) Failing after 40s
Publish Images And Chart / Publish migrations (push) Failing after 39s
Publish Images And Chart / Publish sensor (push) Failing after 34s
Publish Images And Chart / Publish web (push) Failing after 37s
Publish Images And Chart / Publish worker (push) Failing after 39s
Publish Images And Chart / Publish api (push) Failing after 37s
Publish Images And Chart / Publish executor (push) Failing after 36s
Publish Images And Chart / Publish notifier (push) Failing after 36s
Publish Images And Chart / Publish Helm Chart (push) Has been cancelled
2026-03-20 16:40:20 -05:00
16e6b69fc7 updating publish workflow again
Some checks failed
CI / Cargo Audit & Deny (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Web Blocking Checks (push) Has been cancelled
CI / Security Blocking Checks (push) Has been cancelled
CI / Web Advisory Checks (push) Has been cancelled
CI / Security Advisory Checks (push) Has been cancelled
CI / Rustfmt (push) Has been cancelled
CI / Clippy (push) Has been cancelled
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 2s
Publish Images And Chart / Publish init-packs (push) Failing after 33s
Publish Images And Chart / Publish migrations (push) Failing after 21s
Publish Images And Chart / Publish init-user (push) Has started running
Publish Images And Chart / Publish sensor (push) Has started running
Publish Images And Chart / Publish web (push) Has started running
Publish Images And Chart / Publish worker (push) Has been cancelled
Publish Images And Chart / Publish api (push) Has been cancelled
Publish Images And Chart / Publish executor (push) Has been cancelled
Publish Images And Chart / Publish notifier (push) Has been cancelled
Publish Images And Chart / Publish Helm Chart (push) Has been cancelled
2026-03-20 16:33:55 -05:00
a7962eec09 auto-detect cluster registry host
Some checks failed
CI / Rustfmt (push) Successful in 53s
CI / Cargo Audit & Deny (push) Successful in 2m4s
CI / Web Blocking Checks (push) Successful in 4m47s
CI / Security Blocking Checks (push) Successful in 55s
CI / Tests (push) Successful in 8m51s
CI / Security Advisory Checks (push) Successful in 39s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 2s
Publish Images And Chart / Publish init-packs (push) Failing after 15s
Publish Images And Chart / Publish init-user (push) Failing after 13s
CI / Web Advisory Checks (push) Successful in 1m31s
Publish Images And Chart / Publish migrations (push) Failing after 12s
Publish Images And Chart / Publish web (push) Failing after 13s
Publish Images And Chart / Publish worker (push) Failing after 12s
Publish Images And Chart / Publish sensor (push) Failing after 38s
Publish Images And Chart / Publish api (push) Failing after 13s
Publish Images And Chart / Publish notifier (push) Failing after 8s
Publish Images And Chart / Publish executor (push) Failing after 33s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
CI / Clippy (push) Successful in 19m26s
2026-03-20 16:12:45 -05:00
2182be1008 adding git hooks to catch pipeline issues before pushing
Some checks failed
CI / Rustfmt (push) Successful in 51s
CI / Clippy (push) Successful in 2m8s
CI / Web Blocking Checks (push) Successful in 47s
CI / Security Blocking Checks (push) Successful in 9s
CI / Cargo Audit & Deny (push) Successful in 2m3s
CI / Web Advisory Checks (push) Successful in 25s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 1s
Publish Images And Chart / Publish init-packs (push) Failing after 11s
Publish Images And Chart / Publish init-user (push) Failing after 11s
Publish Images And Chart / Publish migrations (push) Failing after 11s
Publish Images And Chart / Publish sensor (push) Failing after 6s
Publish Images And Chart / Publish web (push) Failing after 9s
Publish Images And Chart / Publish worker (push) Failing after 7s
Publish Images And Chart / Publish api (push) Failing after 7s
Publish Images And Chart / Publish executor (push) Failing after 10s
Publish Images And Chart / Publish notifier (push) Failing after 10s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
CI / Security Advisory Checks (push) Successful in 6m20s
CI / Tests (push) Successful in 1h35m45s
2026-03-20 12:56:17 -05:00
43b27044bb formatting 2026-03-20 12:38:12 -05:00
4df621c5c8 adding some initial SSO providers, updating publish workflow
Some checks failed
CI / Rustfmt (push) Failing after 21s
CI / Cargo Audit & Deny (push) Failing after 33s
CI / Web Blocking Checks (push) Successful in 50s
CI / Security Blocking Checks (push) Successful in 7s
CI / Web Advisory Checks (push) Successful in 33s
CI / Security Advisory Checks (push) Successful in 34s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 1s
Publish Images And Chart / Publish init-packs (push) Failing after 11s
Publish Images And Chart / Publish init-user (push) Failing after 10s
Publish Images And Chart / Publish migrations (push) Failing after 11s
Publish Images And Chart / Publish sensor (push) Failing after 10s
Publish Images And Chart / Publish web (push) Failing after 10s
Publish Images And Chart / Publish worker (push) Failing after 10s
Publish Images And Chart / Publish api (push) Failing after 7s
Publish Images And Chart / Publish executor (push) Failing after 9s
Publish Images And Chart / Publish notifier (push) Failing after 10s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
CI / Clippy (push) Successful in 18m52s
CI / Tests (push) Has been cancelled
2026-03-20 12:37:24 -05:00
57fa3bf7cf added oidc adapter
Some checks failed
CI / Rustfmt (push) Failing after 56s
CI / Clippy (push) Successful in 2m4s
CI / Web Blocking Checks (push) Successful in 50s
CI / Cargo Audit & Deny (push) Successful in 2m2s
CI / Security Blocking Checks (push) Successful in 10s
CI / Security Advisory Checks (push) Successful in 41s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 3s
Publish Images And Chart / Publish init-packs (push) Failing after 13s
Publish Images And Chart / Publish init-user (push) Failing after 11s
CI / Web Advisory Checks (push) Successful in 1m38s
Publish Images And Chart / Publish migrations (push) Failing after 11s
Publish Images And Chart / Publish web (push) Failing after 10s
Publish Images And Chart / Publish worker (push) Failing after 10s
Publish Images And Chart / Publish sensor (push) Failing after 31s
Publish Images And Chart / Publish api (push) Failing after 10s
Publish Images And Chart / Publish notifier (push) Failing after 11s
Publish Images And Chart / Publish executor (push) Failing after 31s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
CI / Tests (push) Successful in 1h34m2s
2026-03-18 16:35:21 -05:00
1d59ff5de4 fixing lints
Some checks failed
CI / Rustfmt (push) Successful in 52s
CI / Clippy (push) Failing after 22m37s
CI / Cargo Audit & Deny (push) Successful in 2m11s
CI / Security Blocking Checks (push) Successful in 52s
CI / Web Advisory Checks (push) Failing after 20m36s
CI / Web Blocking Checks (push) Failing after 38m23s
CI / Security Advisory Checks (push) Failing after 11m48s
CI / Tests (push) Failing after 1h32m20s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 5s
Publish Images And Chart / Publish migrations (push) Failing after 39s
Publish Images And Chart / Publish sensor (push) Failing after 33s
Publish Images And Chart / Publish web (push) Failing after 34s
Publish Images And Chart / Publish init-user (push) Failing after 2m0s
Publish Images And Chart / Publish worker (push) Failing after 33s
Publish Images And Chart / Publish api (push) Failing after 32s
Publish Images And Chart / Publish executor (push) Failing after 34s
Publish Images And Chart / Publish notifier (push) Failing after 37s
Publish Images And Chart / Publish init-packs (push) Failing after 12m15s
Publish Images And Chart / Publish Helm Chart (push) Has been cancelled
2026-03-17 14:51:19 -05:00
f96861d417 properly handling patch updates
Some checks failed
CI / Clippy (push) Failing after 3m6s
CI / Rustfmt (push) Failing after 3m9s
CI / Cargo Audit & Deny (push) Successful in 5m2s
CI / Tests (push) Successful in 8m15s
CI / Security Blocking Checks (push) Successful in 10s
CI / Web Advisory Checks (push) Successful in 1m4s
CI / Web Blocking Checks (push) Failing after 4m52s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 2s
CI / Security Advisory Checks (push) Successful in 1m31s
Publish Images And Chart / Publish init-user (push) Failing after 30s
Publish Images And Chart / Publish init-packs (push) Failing after 1m41s
Publish Images And Chart / Publish migrations (push) Failing after 10s
Publish Images And Chart / Publish web (push) Failing after 11s
Publish Images And Chart / Publish sensor (push) Failing after 32s
Publish Images And Chart / Publish worker (push) Failing after 11s
Publish Images And Chart / Publish executor (push) Failing after 11s
Publish Images And Chart / Publish notifier (push) Failing after 9s
Publish Images And Chart / Publish api (push) Failing after 31s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
2026-03-17 12:17:58 -05:00
643023b6d5 updating dockerignore
Some checks failed
CI / Rustfmt (push) Successful in 19s
CI / Clippy (push) Successful in 1m57s
CI / Cargo Audit & Deny (push) Successful in 31s
CI / Web Blocking Checks (push) Successful in 1m36s
CI / Security Blocking Checks (push) Successful in 11s
CI / Web Advisory Checks (push) Successful in 34s
CI / Security Advisory Checks (push) Successful in 1m32s
CI / Tests (push) Successful in 8m54s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 3s
Publish Images And Chart / Publish Helm Chart (push) Has been cancelled
Publish Images And Chart / Publish init-user (push) Failing after 8s
Publish Images And Chart / Publish sensor (push) Failing after 13s
Publish Images And Chart / Publish init-packs (push) Failing after 19s
Publish Images And Chart / Publish worker (push) Failing after 13s
Publish Images And Chart / Publish api (push) Failing after 14s
Publish Images And Chart / Publish notifier (push) Failing after 13s
Publish Images And Chart / Publish executor (push) Failing after 10s
Publish Images And Chart / Publish web (push) Failing after 11m22s
Publish Images And Chart / Publish migrations (push) Failing after 11m37s
2026-03-16 09:11:51 -05:00
feb070c165 [wip] helmchart
Some checks failed
CI / Rustfmt (push) Successful in 22s
CI / Clippy (push) Successful in 1m55s
CI / Cargo Audit & Deny (push) Successful in 42s
CI / Web Blocking Checks (push) Successful in 1m24s
CI / Tests (push) Successful in 8m21s
CI / Web Advisory Checks (push) Successful in 1m12s
Publish Images And Chart / Publish init-user (push) Failing after 1m0s
Publish Images And Chart / Publish migrations (push) Failing after 23s
Publish Images And Chart / Publish sensor (push) Failing after 19s
Publish Images And Chart / Publish worker (push) Failing after 17s
Publish Images And Chart / Publish api (push) Failing after 17s
Publish Images And Chart / Publish executor (push) Failing after 17s
Publish Images And Chart / Publish web (push) Failing after 1m33s
Publish Images And Chart / Publish notifier (push) Failing after 54s
CI / Security Blocking Checks (push) Successful in 10s
CI / Security Advisory Checks (push) Successful in 1m25s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 2s
Publish Images And Chart / Publish init-packs (push) Failing after 27s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
2026-03-16 08:31:19 -05:00
6a86dd7ca6 [wip]helmchart
Some checks failed
CI / Rustfmt (push) Successful in 1m30s
Publish Images And Chart / Resolve Publish Metadata (push) Failing after 2s
Publish Images And Chart / Publish init-packs (push) Has been skipped
Publish Images And Chart / Publish init-user (push) Has been skipped
Publish Images And Chart / Publish migrations (push) Has been skipped
Publish Images And Chart / Publish sensor (push) Has been skipped
Publish Images And Chart / Publish web (push) Has been skipped
Publish Images And Chart / Publish worker (push) Has been skipped
Publish Images And Chart / Publish api (push) Has been skipped
Publish Images And Chart / Publish executor (push) Has been skipped
Publish Images And Chart / Publish notifier (push) Has been skipped
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
CI / Web Blocking Checks (push) Successful in 1m55s
CI / Security Advisory Checks (push) Failing after 13m14s
CI / Web Advisory Checks (push) Failing after 13m20s
CI / Security Blocking Checks (push) Failing after 13m31s
CI / Cargo Audit & Deny (push) Failing after 14m51s
CI / Tests (push) Failing after 14m53s
CI / Clippy (push) Failing after 14m59s
2026-03-14 18:11:10 -05:00
6307888722 fixing tests
All checks were successful
CI / Rustfmt (push) Successful in 23s
CI / Cargo Audit & Deny (push) Successful in 34s
CI / Web Blocking Checks (push) Successful in 49s
CI / Security Blocking Checks (push) Successful in 9s
CI / Clippy (push) Successful in 2m6s
CI / Web Advisory Checks (push) Successful in 24s
CI / Security Advisory Checks (push) Successful in 37s
CI / Tests (push) Successful in 7m39s
2026-03-11 14:53:15 -05:00
9b0ff4a6d2 linting
Some checks failed
CI / Rustfmt (push) Successful in 22s
CI / Cargo Audit & Deny (push) Successful in 36s
CI / Web Blocking Checks (push) Successful in 50s
CI / Security Blocking Checks (push) Successful in 9s
CI / Clippy (push) Successful in 2m2s
CI / Web Advisory Checks (push) Successful in 33s
CI / Security Advisory Checks (push) Successful in 38s
CI / Tests (push) Failing after 8m12s
2026-03-11 12:55:24 -05:00
5c0ff6f271 fixing lint issues
Some checks failed
CI / Rustfmt (push) Successful in 23s
CI / Cargo Audit & Deny (push) Successful in 33s
CI / Clippy (push) Failing after 1m55s
CI / Web Blocking Checks (push) Successful in 47s
CI / Security Blocking Checks (push) Successful in 9s
CI / Web Advisory Checks (push) Successful in 30s
CI / Security Advisory Checks (push) Successful in 31s
CI / Tests (push) Failing after 8m6s
2026-03-11 11:57:06 -05:00
1645ad84ee fixing lint issues
Some checks failed
CI / Cargo Audit & Deny (push) Has been cancelled
CI / Web Blocking Checks (push) Has been cancelled
CI / Security Blocking Checks (push) Has been cancelled
CI / Web Advisory Checks (push) Has been cancelled
CI / Security Advisory Checks (push) Has been cancelled
CI / Rustfmt (push) Has started running
CI / Clippy (push) Has been cancelled
CI / Tests (push) Has been cancelled
2026-03-11 11:56:57 -05:00
765afc7d76 cargo format
Some checks failed
CI / Rustfmt (push) Successful in 22s
CI / Cargo Audit & Deny (push) Successful in 33s
CI / Web Blocking Checks (push) Failing after 29s
CI / Security Blocking Checks (push) Successful in 9s
CI / Clippy (push) Failing after 1m59s
CI / Web Advisory Checks (push) Successful in 31s
CI / Security Advisory Checks (push) Successful in 36s
CI / Tests (push) Failing after 8m8s
2026-03-11 11:24:50 -05:00
b5d6bb2243 more polish on workflows
Some checks failed
CI / Rustfmt (push) Failing after 25s
CI / Clippy (push) Failing after 2m3s
CI / Cargo Audit & Deny (push) Successful in 33s
CI / Web Blocking Checks (push) Failing after 26s
CI / Security Blocking Checks (push) Successful in 8s
CI / Security Advisory Checks (push) Has been cancelled
CI / Web Advisory Checks (push) Has been cancelled
CI / Tests (push) Has been cancelled
2026-03-11 11:21:28 -05:00
a7ed135af2 more edge case resolution on workflow builder
Some checks failed
CI / Rustfmt (push) Successful in 22s
CI / Cargo Audit & Deny (push) Successful in 32s
CI / Web Blocking Checks (push) Failing after 26s
CI / Security Blocking Checks (push) Successful in 8s
CI / Clippy (push) Failing after 2m0s
CI / Web Advisory Checks (push) Successful in 32s
CI / Security Advisory Checks (push) Successful in 37s
CI / Tests (push) Failing after 7m33s
2026-03-11 09:29:17 -05:00
71ea3f34ca cancelling actions works now 2026-03-10 19:53:20 -05:00
5b45b17fa6 [wip] single runtime handling 2026-03-10 09:30:57 -05:00
9e7e35cbe3 [wip] workflow cancellation policy
Some checks failed
CI / Rustfmt (push) Successful in 21s
CI / Cargo Audit & Deny (push) Successful in 32s
CI / Web Blocking Checks (push) Successful in 50s
CI / Security Blocking Checks (push) Successful in 9s
CI / Clippy (push) Failing after 1m58s
CI / Web Advisory Checks (push) Successful in 34s
CI / Security Advisory Checks (push) Successful in 1m26s
CI / Tests (push) Successful in 8m47s
2026-03-09 14:08:01 -05:00
87d830f952 [wip] cli capability parity
Some checks failed
CI / Rustfmt (push) Successful in 23s
CI / Cargo Audit & Deny (push) Successful in 30s
CI / Web Blocking Checks (push) Successful in 48s
CI / Security Blocking Checks (push) Successful in 8s
CI / Clippy (push) Failing after 1m55s
CI / Web Advisory Checks (push) Successful in 35s
CI / Security Advisory Checks (push) Successful in 37s
CI / Tests (push) Successful in 8m5s
2026-03-06 16:58:50 -06:00
48b6ca6bd7 marking integration tests
Some checks failed
CI / Rustfmt (push) Successful in 22s
CI / Clippy (push) Failing after 1m54s
CI / Cargo Audit & Deny (push) Successful in 33s
CI / Web Blocking Checks (push) Successful in 49s
CI / Security Blocking Checks (push) Successful in 8s
CI / Web Advisory Checks (push) Successful in 32s
CI / Security Advisory Checks (push) Successful in 37s
CI / Tests (push) Failing after 8m46s
2026-03-05 16:33:06 -06:00
4b0000c116 no more cargo advisories ignored 2026-03-05 15:48:35 -06:00
9af3192d1d hopefully resolving cargo audit
Some checks failed
CI / Rustfmt (push) Successful in 19s
CI / Cargo Audit & Deny (push) Successful in 29s
CI / Web Blocking Checks (push) Successful in 48s
CI / Security Blocking Checks (push) Successful in 8s
CI / Clippy (push) Successful in 2m2s
CI / Web Advisory Checks (push) Successful in 32s
CI / Security Advisory Checks (push) Successful in 35s
CI / Tests (push) Failing after 7m54s
2026-03-05 14:30:29 -06:00
649648896e Update license from MIT to Apache 2.0
Some checks failed
CI / Rustfmt (push) Successful in 23s
CI / Clippy (push) Successful in 2m0s
CI / Cargo Audit & Deny (push) Failing after 32s
CI / Web Blocking Checks (push) Successful in 50s
CI / Security Blocking Checks (push) Successful in 9s
CI / Web Advisory Checks (push) Successful in 32s
CI / Security Advisory Checks (push) Successful in 36s
CI / Tests (push) Failing after 7m57s
2026-03-05 09:48:42 -06:00
a00f7c80fb audit stuff 2026-03-05 09:27:59 -06:00
434 changed files with 36911 additions and 7620 deletions

0
.codex_write_test Normal file
View File

View File

@@ -50,8 +50,8 @@ web/node_modules/
web/dist/ web/dist/
web/.vite/ web/.vite/
# SQLx offline data (generated at build time) # SQLx offline data (generated when using `cargo sqlx prepare`)
#.sqlx/ # .sqlx/
# Configuration files (copied selectively) # Configuration files (copied selectively)
config.development.yaml config.development.yaml
@@ -61,6 +61,7 @@ config.example.yaml
# Scripts (not needed in runtime) # Scripts (not needed in runtime)
scripts/ scripts/
!scripts/load_core_pack.py
# Cargo lock (workspace handles this) # Cargo lock (workspace handles this)
# Uncomment if you want deterministic builds: # Uncomment if you want deterministic builds:

View File

@@ -9,10 +9,12 @@ on:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
RUST_MIN_STACK: 16777216 RUST_MIN_STACK: 67108864
CARGO_INCREMENTAL: 0 CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10 CARGO_NET_RETRY: 10
RUSTUP_MAX_RETRIES: 10 RUSTUP_MAX_RETRIES: 10
# Gitea Actions runner tool cache. Actions like setup-node/setup-python can reuse this.
RUNNER_TOOL_CACHE: /toolcache
jobs: jobs:
rust-fmt: rust-fmt:
@@ -22,6 +24,17 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Cache Rust toolchain
uses: actions/cache@v4
with:
path: |
~/.rustup/toolchains
~/.rustup/update-hashes
key: rustup-rustfmt-${{ runner.os }}-stable-v1
restore-keys: |
rustup-${{ runner.os }}-stable-v1
rustup-
- name: Setup Rust - name: Setup Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
@@ -37,6 +50,17 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Cache Rust toolchain
uses: actions/cache@v4
with:
path: |
~/.rustup/toolchains
~/.rustup/update-hashes
key: rustup-clippy-${{ runner.os }}-stable-v1
restore-keys: |
rustup-${{ runner.os }}-stable-v1
rustup-
- name: Setup Rust - name: Setup Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
@@ -72,6 +96,17 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Cache Rust toolchain
uses: actions/cache@v4
with:
path: |
~/.rustup/toolchains
~/.rustup/update-hashes
key: rustup-test-${{ runner.os }}-stable-v1
restore-keys: |
rustup-${{ runner.os }}-stable-v1
rustup-
- name: Setup Rust - name: Setup Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -105,6 +140,17 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Cache Rust toolchain
uses: actions/cache@v4
with:
path: |
~/.rustup/toolchains
~/.rustup/update-hashes
key: rustup-audit-${{ runner.os }}-stable-v1
restore-keys: |
rustup-${{ runner.os }}-stable-v1
rustup-
- name: Setup Rust - name: Setup Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -124,9 +170,8 @@ jobs:
with: with:
path: | path: |
~/.cargo/bin/cargo-binstall ~/.cargo/bin/cargo-binstall
~/.cargo/bin/cargo-audit
~/.cargo/bin/cargo-deny ~/.cargo/bin/cargo-deny
key: cargo-security-tools-v1 key: cargo-security-tools-v2
- name: Install cargo-binstall - name: Install cargo-binstall
run: | run: |
@@ -136,12 +181,8 @@ jobs:
- name: Install security tools (pre-built binaries) - name: Install security tools (pre-built binaries)
run: | run: |
command -v cargo-audit &> /dev/null || cargo binstall --no-confirm --locked cargo-audit
command -v cargo-deny &> /dev/null || cargo binstall --no-confirm --locked cargo-deny command -v cargo-deny &> /dev/null || cargo binstall --no-confirm --locked cargo-deny
- name: Cargo Audit
run: cargo audit
- name: Cargo Deny - name: Cargo Deny
run: cargo deny check run: cargo deny check

View File

@@ -0,0 +1,640 @@
name: Publish Images
on:
workflow_dispatch:
inputs:
target_arch:
description: Architecture to publish
type: choice
options:
- all
- amd64
- arm64
default: all
target_image:
description: Image to publish
type: choice
options:
- all
- api
- executor
- notifier
- agent
- web
default: all
push:
branches:
- main
- master
tags:
- "v*"
env:
REGISTRY_HOST: ${{ vars.CLUSTER_GITEA_HOST }}
REGISTRY_NAMESPACE: ${{ vars.CONTAINER_REGISTRY_NAMESPACE }}
REGISTRY_PLAIN_HTTP: ${{ vars.CONTAINER_REGISTRY_INSECURE }}
ARTIFACT_REPOSITORY: attune-build-artifacts
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
RUSTUP_MAX_RETRIES: 10
RUST_MIN_STACK: 67108864
SQLX_OFFLINE: true
RUNNER_TOOL_CACHE: /toolcache
jobs:
metadata:
name: Resolve Publish Metadata
runs-on: build-amd64
outputs:
registry: ${{ steps.meta.outputs.registry }}
namespace: ${{ steps.meta.outputs.namespace }}
registry_plain_http: ${{ steps.meta.outputs.registry_plain_http }}
image_tag: ${{ steps.meta.outputs.image_tag }}
image_tags: ${{ steps.meta.outputs.image_tags }}
artifact_ref_base: ${{ steps.meta.outputs.artifact_ref_base }}
steps:
- name: Resolve tags and registry paths
id: meta
shell: bash
run: |
set -euo pipefail
registry="${REGISTRY_HOST}"
namespace="${REGISTRY_NAMESPACE}"
registry_plain_http_raw="${REGISTRY_PLAIN_HTTP:-}"
registry_host_only="${registry%%:*}"
registry_plain_http_default="false"
if [ -z "$registry" ]; then
echo "CLUSTER_GITEA_HOST app variable is required"
exit 1
fi
if [ -z "$namespace" ]; then
namespace="${{ github.repository_owner }}"
fi
if printf '%s' "$registry_host_only" | grep -Eq '(^|[.])svc[.]cluster[.]local$'; then
registry_plain_http_default="true"
fi
if [ -n "$registry_plain_http_raw" ]; then
case "$(printf '%s' "$registry_plain_http_raw" | tr '[:upper:]' '[:lower:]')" in
1|true|yes|on)
registry_plain_http="true"
;;
0|false|no|off)
registry_plain_http="false"
;;
*)
echo "CONTAINER_REGISTRY_INSECURE must be a boolean when set"
exit 1
;;
esac
else
registry_plain_http="$registry_plain_http_default"
fi
short_sha="$(printf '%s' "${{ github.sha }}" | cut -c1-12)"
ref_type="${{ github.ref_type }}"
ref_name="${{ github.ref_name }}"
if [ "$ref_type" = "tag" ] && printf '%s' "$ref_name" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-.].*)?$'; then
version="${ref_name#v}"
image_tags="${version},latest,sha-${short_sha}"
else
version="sha-${short_sha}"
image_tags="edge,sha-${short_sha}"
fi
artifact_ref_base="${registry}/${namespace}/${ARTIFACT_REPOSITORY}"
{
echo "registry=$registry"
echo "namespace=$namespace"
echo "registry_plain_http=$registry_plain_http"
echo "image_tag=$version"
echo "image_tags=$image_tags"
echo "artifact_ref_base=$artifact_ref_base"
} >> "$GITHUB_OUTPUT"
build-rust-bundles:
name: Build Rust Bundles (${{ matrix.arch }})
runs-on: ${{ matrix.runner_label }}
needs: metadata
if: |
github.event_name != 'workflow_dispatch' ||
inputs.target_arch == 'all' ||
inputs.target_arch == matrix.arch
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner_label: build-amd64
musl_target: x86_64-unknown-linux-musl
- arch: arm64
runner_label: build-arm64
musl_target: aarch64-unknown-linux-musl
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache Rust toolchain
uses: actions/cache@v4
with:
path: |
~/.rustup/toolchains
~/.rustup/update-hashes
key: rustup-publish-${{ runner.os }}-${{ matrix.arch }}-stable-v1
restore-keys: |
rustup-${{ runner.os }}-${{ matrix.arch }}-stable-v1
rustup-${{ runner.os }}-stable-v1
rustup-
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.musl_target }}
- name: Cache Cargo registry + index
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: cargo-registry-publish-${{ matrix.arch }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-registry-publish-${{ matrix.arch }}-
cargo-registry-
- name: Cache Cargo build artifacts
uses: actions/cache@v4
with:
path: target
key: cargo-publish-${{ matrix.arch }}-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('**/*.rs', '**/Cargo.toml') }}
restore-keys: |
cargo-publish-${{ matrix.arch }}-${{ hashFiles('**/Cargo.lock') }}-
cargo-publish-${{ matrix.arch }}-
- name: Install native build dependencies
shell: bash
run: |
set -euo pipefail
apt-get update
apt-get install -y pkg-config libssl-dev musl-tools file
- name: Build release binaries
shell: bash
run: |
set -euo pipefail
cargo build --release \
--bin attune-api \
--bin attune-executor \
--bin attune-notifier
- name: Build static agent binaries
shell: bash
run: |
set -euo pipefail
cargo build --release \
--target "${{ matrix.musl_target }}" \
--bin attune-agent \
--bin attune-sensor-agent
- name: Assemble binary bundle
shell: bash
run: |
set -euo pipefail
bundle_root="dist/bundle/${{ matrix.arch }}"
mkdir -p "$bundle_root/bin" "$bundle_root/agent"
cp target/release/attune-api "$bundle_root/bin/"
cp target/release/attune-executor "$bundle_root/bin/"
cp target/release/attune-notifier "$bundle_root/bin/"
cp target/${{ matrix.musl_target }}/release/attune-agent "$bundle_root/agent/"
cp target/${{ matrix.musl_target }}/release/attune-sensor-agent "$bundle_root/agent/"
cat > "$bundle_root/metadata.json" <<EOF
{
"git_sha": "${{ github.sha }}",
"ref": "${{ github.ref }}",
"arch": "${{ matrix.arch }}",
"image_tag": "${{ needs.metadata.outputs.image_tag }}"
}
EOF
tar -C dist/bundle/${{ matrix.arch }} -czf "dist/attune-binaries-${{ matrix.arch }}.tar.gz" .
- name: Setup ORAS
uses: oras-project/setup-oras@v1
- name: Log in to registry for artifacts
shell: bash
env:
REGISTRY_USERNAME: ${{ secrets.CONTAINER_REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }}
GITHUB_TOKEN_FALLBACK: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
registry_username="${REGISTRY_USERNAME:-${{ github.actor }}}"
registry_password="${REGISTRY_PASSWORD:-${GITHUB_TOKEN_FALLBACK:-}}"
login_args=()
if [ -z "$registry_password" ]; then
echo "Set CONTAINER_REGISTRY_PASSWORD or enable GITHUB_TOKEN package writes"
exit 1
fi
if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then
login_args+=(--plain-http)
fi
oras login "${{ needs.metadata.outputs.registry }}" \
"${login_args[@]}" \
--username "$registry_username" \
--password "$registry_password"
- name: Push binary bundle artifact
shell: bash
run: |
set -euo pipefail
push_args=()
if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then
push_args+=(--plain-http)
fi
oras push \
"${push_args[@]}" \
"${{ needs.metadata.outputs.artifact_ref_base }}:rust-binaries-${{ needs.metadata.outputs.image_tag }}-${{ matrix.arch }}" \
--artifact-type application/vnd.attune.rust-binaries.v1 \
"dist/attune-binaries-${{ matrix.arch }}.tar.gz:application/vnd.attune.rust-binaries.layer.v1.tar+gzip"
publish-rust-images:
name: Publish ${{ matrix.image.name }} (${{ matrix.arch }})
runs-on: ${{ matrix.runner_label }}
needs:
- metadata
- build-rust-bundles
if: |
(github.event_name != 'workflow_dispatch' ||
inputs.target_arch == 'all' ||
inputs.target_arch == matrix.arch) &&
(github.event_name != 'workflow_dispatch' ||
inputs.target_image == 'all' ||
inputs.target_image == matrix.image.name)
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner_label: build-amd64
platform: linux/amd64
image:
name: api
repository: attune-api
source_path: bin/attune-api
dockerfile: docker/Dockerfile.runtime
- arch: amd64
runner_label: build-amd64
platform: linux/amd64
image:
name: executor
repository: attune-executor
source_path: bin/attune-executor
dockerfile: docker/Dockerfile.runtime
- arch: amd64
runner_label: build-amd64
platform: linux/amd64
image:
name: notifier
repository: attune-notifier
source_path: bin/attune-notifier
dockerfile: docker/Dockerfile.runtime
- arch: amd64
runner_label: build-amd64
platform: linux/amd64
image:
name: agent
repository: attune-agent
source_path: agent/attune-agent
dockerfile: docker/Dockerfile.agent-package
- arch: arm64
runner_label: build-arm64
platform: linux/arm64
image:
name: api
repository: attune-api
source_path: bin/attune-api
dockerfile: docker/Dockerfile.runtime
- arch: arm64
runner_label: build-arm64
platform: linux/arm64
image:
name: executor
repository: attune-executor
source_path: bin/attune-executor
dockerfile: docker/Dockerfile.runtime
- arch: arm64
runner_label: build-arm64
platform: linux/arm64
image:
name: notifier
repository: attune-notifier
source_path: bin/attune-notifier
dockerfile: docker/Dockerfile.runtime
- arch: arm64
runner_label: build-arm64
platform: linux/arm64
image:
name: agent
repository: attune-agent
source_path: agent/attune-agent
dockerfile: docker/Dockerfile.agent-package
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup ORAS
uses: oras-project/setup-oras@v1
- name: Setup Docker Buildx
if: needs.metadata.outputs.registry_plain_http != 'true'
uses: docker/setup-buildx-action@v3
- name: Setup Docker Buildx For Plain HTTP Registry
if: needs.metadata.outputs.registry_plain_http == 'true'
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
[registry."${{ needs.metadata.outputs.registry }}"]
http = true
insecure = true
- name: Log in to registry
shell: bash
env:
REGISTRY_USERNAME: ${{ secrets.CONTAINER_REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }}
GITHUB_TOKEN_FALLBACK: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
registry_username="${REGISTRY_USERNAME:-${{ github.actor }}}"
registry_password="${REGISTRY_PASSWORD:-${GITHUB_TOKEN_FALLBACK:-}}"
if [ -z "$registry_password" ]; then
echo "Set CONTAINER_REGISTRY_PASSWORD or enable GITHUB_TOKEN package writes"
exit 1
fi
mkdir -p "$HOME/.docker"
auth="$(printf '%s:%s' "$registry_username" "$registry_password" | base64 | tr -d '\n')"
cat > "$HOME/.docker/config.json" <<EOF
{
"auths": {
"${{ needs.metadata.outputs.registry }}": {
"auth": "${auth}"
}
}
}
EOF
oras_login_args=()
if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then
oras_login_args+=(--plain-http)
fi
oras login "${{ needs.metadata.outputs.registry }}" \
"${oras_login_args[@]}" \
--username "$registry_username" \
--password "$registry_password"
- name: Pull binary bundle
shell: bash
run: |
set -euo pipefail
pull_args=()
if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then
pull_args+=(--plain-http)
fi
mkdir -p dist/artifact
cd dist/artifact
oras pull \
"${pull_args[@]}" \
"${{ needs.metadata.outputs.artifact_ref_base }}:rust-binaries-${{ needs.metadata.outputs.image_tag }}-${{ matrix.arch }}"
tar -xzf "attune-binaries-${{ matrix.arch }}.tar.gz"
- name: Prepare packaging context
shell: bash
run: |
set -euo pipefail
rm -rf dist/image
mkdir -p dist/image
case "${{ matrix.image.name }}" in
api|executor|notifier)
cp "dist/artifact/${{ matrix.image.source_path }}" dist/attune-service-binary
;;
agent)
cp dist/artifact/agent/attune-agent dist/attune-agent
cp dist/artifact/agent/attune-sensor-agent dist/attune-sensor-agent
;;
*)
echo "Unsupported image: ${{ matrix.image.name }}"
exit 1
;;
esac
- name: Push architecture image
shell: bash
run: |
set -euo pipefail
image_ref="${{ needs.metadata.outputs.registry }}/${{ needs.metadata.outputs.namespace }}/${{ matrix.image.repository }}:${{ needs.metadata.outputs.image_tag }}-${{ matrix.arch }}"
build_cmd=(
docker buildx build
.
--platform "${{ matrix.platform }}"
--file "${{ matrix.image.dockerfile }}"
)
if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then
build_cmd+=(--output "type=image,\"name=${image_ref}\",push=true,registry.insecure=true")
else
build_cmd+=(--tag "$image_ref" --push)
fi
"${build_cmd[@]}"
publish-web-images:
name: Publish web (${{ matrix.arch }})
runs-on: ${{ matrix.runner_label }}
needs: metadata
if: |
(github.event_name != 'workflow_dispatch' ||
inputs.target_arch == 'all' ||
inputs.target_arch == matrix.arch) &&
(github.event_name != 'workflow_dispatch' ||
inputs.target_image == 'all' ||
inputs.target_image == 'web')
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner_label: build-amd64
platform: linux/amd64
- arch: arm64
runner_label: build-arm64
platform: linux/arm64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Docker Buildx
if: needs.metadata.outputs.registry_plain_http != 'true'
uses: docker/setup-buildx-action@v3
- name: Setup Docker Buildx For Plain HTTP Registry
if: needs.metadata.outputs.registry_plain_http == 'true'
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
[registry."${{ needs.metadata.outputs.registry }}"]
http = true
insecure = true
- name: Configure OCI registry auth
shell: bash
env:
REGISTRY_USERNAME: ${{ secrets.CONTAINER_REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }}
GITHUB_TOKEN_FALLBACK: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
username="${REGISTRY_USERNAME:-${{ github.actor }}}"
password="${REGISTRY_PASSWORD:-${GITHUB_TOKEN_FALLBACK:-}}"
if [ -z "$password" ]; then
echo "Set CONTAINER_REGISTRY_PASSWORD or enable GITHUB_TOKEN package writes"
exit 1
fi
mkdir -p "$HOME/.docker"
auth="$(printf '%s:%s' "$username" "$password" | base64 | tr -d '\n')"
cat > "$HOME/.docker/config.json" <<EOF
{
"auths": {
"${{ needs.metadata.outputs.registry }}": {
"auth": "${auth}"
}
}
}
EOF
- name: Push architecture image
shell: bash
run: |
set -euo pipefail
image_ref="${{ needs.metadata.outputs.registry }}/${{ needs.metadata.outputs.namespace }}/attune-web:${{ needs.metadata.outputs.image_tag }}-${{ matrix.arch }}"
build_cmd=(
docker buildx build
.
--platform "${{ matrix.platform }}"
--file docker/Dockerfile.web
)
if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then
build_cmd+=(--output "type=image,\"name=${image_ref}\",push=true,registry.insecure=true")
else
build_cmd+=(--tag "$image_ref" --push)
fi
"${build_cmd[@]}"
publish-manifests:
name: Publish manifest ${{ matrix.repository }}
runs-on: build-amd64
needs:
- metadata
- publish-rust-images
- publish-web-images
if: |
github.event_name != 'workflow_dispatch' ||
(inputs.target_arch == 'all' && inputs.target_image == 'all')
strategy:
fail-fast: false
matrix:
repository:
- attune-api
- attune-executor
- attune-notifier
- attune-agent
- attune-web
steps:
- name: Configure OCI registry auth
shell: bash
env:
REGISTRY_USERNAME: ${{ secrets.CONTAINER_REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }}
GITHUB_TOKEN_FALLBACK: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
username="${REGISTRY_USERNAME:-${{ github.actor }}}"
password="${REGISTRY_PASSWORD:-${GITHUB_TOKEN_FALLBACK:-}}"
if [ -z "$password" ]; then
echo "Set CONTAINER_REGISTRY_PASSWORD or enable GITHUB_TOKEN package writes"
exit 1
fi
mkdir -p "$HOME/.docker"
auth="$(printf '%s:%s' "$username" "$password" | base64 | tr -d '\n')"
cat > "$HOME/.docker/config.json" <<EOF
{
"auths": {
"${{ needs.metadata.outputs.registry }}": {
"auth": "${auth}"
}
}
}
EOF
- name: Publish manifest tags
shell: bash
run: |
set -euo pipefail
image_base="${{ needs.metadata.outputs.registry }}/${{ needs.metadata.outputs.namespace }}/${{ matrix.repository }}"
push_args=()
if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then
push_args+=(--insecure)
fi
IFS=',' read -ra tags <<< "${{ needs.metadata.outputs.image_tags }}"
for tag in "${tags[@]}"; do
manifest_ref="${image_base}:${tag}"
amd64_ref="${image_base}:${{ needs.metadata.outputs.image_tag }}-amd64"
arm64_ref="${image_base}:${{ needs.metadata.outputs.image_tag }}-arm64"
docker manifest rm "$manifest_ref" >/dev/null 2>&1 || true
docker manifest create "$manifest_ref" "$amd64_ref" "$arm64_ref"
docker manifest annotate "$manifest_ref" "$amd64_ref" --os linux --arch amd64
docker manifest annotate "$manifest_ref" "$arm64_ref" --os linux --arch arm64
docker manifest push "${push_args[@]}" "$manifest_ref"
done

15
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"
echo "Formatting Rust code..."
cargo fmt --all
echo "Refreshing staged Rust files..."
git add --all '*.rs'
echo "Running pre-commit checks..."
make pre-commit

3
.gitignore vendored
View File

@@ -1,6 +1,5 @@
# Rust # Rust
target/ target/
Cargo.lock
**/*.rs.bk **/*.rs.bk
*.pdb *.pdb
@@ -79,3 +78,5 @@ docker-compose.override.yml
*.pid *.pid
packs.examples/ packs.examples/
packs.external/
codex/

View File

@@ -2,6 +2,5 @@ target/
web/dist/ web/dist/
web/node_modules/ web/node_modules/
web/src/api/ web/src/api/
packs/
packs.dev/ packs.dev/
packs.external/ packs.external/

File diff suppressed because one or more lines are too long

View File

@@ -1,430 +0,0 @@
# Attune Project Rules
## Project Overview
Attune is an **event-driven automation and orchestration platform** built in Rust, similar to StackStorm. It enables building complex workflows triggered by events with multi-tenancy, RBAC, and human-in-the-loop capabilities.
## Development Status: Pre-Production
**This project is under active development with no users, deployments, or stable releases.**
### Breaking Changes Policy
- **Breaking changes are explicitly allowed and encouraged** when they improve the architecture, API design, or developer experience
- **No backward compatibility required** - there are no existing versions to support
- **Database migrations can be modified or consolidated** - no production data exists
- **API contracts can change freely** - no external integrations depend on them, only internal interfaces with other services and the web UI must be maintained.
- **Configuration formats can be redesigned** - no existing config files need migration
- **Service interfaces can be refactored** - no live deployments to worry about
When this project reaches v1.0 or gets its first production deployment, this section should be removed and replaced with appropriate stability guarantees and versioning policies.
## Languages & Core Technologies
- **Primary Language**: Rust 2021 edition
- **Database**: PostgreSQL 14+ (primary data store + LISTEN/NOTIFY pub/sub)
- **Message Queue**: RabbitMQ 3.12+ (via lapin)
- **Cache**: Redis 7.0+ (optional)
- **Web UI**: TypeScript + React 19 + Vite
- **Async Runtime**: Tokio
- **Web Framework**: Axum 0.8
- **ORM**: SQLx (compile-time query checking)
## Project Structure (Cargo Workspace)
```
attune/
├── Cargo.toml # Workspace root
├── config.{development,test}.yaml # Environment configs
├── Makefile # Common dev tasks
├── crates/ # Rust services
│ ├── common/ # Shared library (models, db, repos, mq, config, error)
│ ├── api/ # REST API service (8080)
│ ├── executor/ # Execution orchestration service
│ ├── worker/ # Action execution service (multi-runtime)
│ ├── sensor/ # Event monitoring service
│ ├── notifier/ # Real-time notification service
│ └── cli/ # Command-line interface
├── migrations/ # SQLx database migrations (18 tables)
├── web/ # React web UI (Vite + TypeScript)
├── packs/ # Pack bundles
│ └── core/ # Core pack (timers, HTTP, etc.)
├── docs/ # Technical documentation
├── scripts/ # Helper scripts (DB setup, testing)
└── tests/ # Integration tests
```
## Service Architecture (Distributed Microservices)
1. **attune-api**: REST API gateway, JWT auth, all client interactions
2. **attune-executor**: Manages execution lifecycle, scheduling, policy enforcement
3. **attune-worker**: Executes actions in multiple runtimes (Python/Node.js/containers)
4. **attune-sensor**: Monitors triggers, generates events
5. **attune-notifier**: Real-time notifications via PostgreSQL LISTEN/NOTIFY + WebSocket
**Communication**: Services communicate via RabbitMQ for async operations
## Docker Compose Orchestration
**All Attune services run via Docker Compose.**
- **Compose file**: `docker-compose.yaml` (root directory)
- **Configuration**: `config.docker.yaml` (Docker-specific settings)
- **Default user**: `test@attune.local` / `TestPass123!` (auto-created)
**Services**:
- **Infrastructure**: postgres, rabbitmq, redis
- **Init** (run-once): migrations, init-user, init-packs
- **Application**: api (8080), executor, worker-{shell,python,node,full}, sensor, notifier (8081), web (3000)
**Commands**:
```bash
docker compose up -d # Start all services
docker compose down # Stop all services
docker compose logs -f <svc> # View logs
```
**Key environment overrides**: `JWT_SECRET`, `ENCRYPTION_KEY` (required for production)
## Domain Model & Event Flow
**Critical Event Flow**:
```
Sensor → Trigger fires → Event created → Rule evaluates →
Enforcement created → Execution scheduled → Worker executes Action
```
**Key Entities** (all in `public` schema, IDs are `i64`):
- **Pack**: Bundle of automation components (actions, sensors, rules, triggers)
- **Trigger**: Event type definition (e.g., "webhook_received")
- **Sensor**: Monitors for trigger conditions, creates events
- **Event**: Instance of a trigger firing with payload
- **Action**: Executable task with parameters
- **Rule**: Links triggers to actions with conditional logic
- **Enforcement**: Represents a rule activation
- **Execution**: Single action run; supports parent-child relationships for workflows
- **Workflow Tasks**: Workflow-specific metadata stored in `execution.workflow_task` JSONB field
- **Inquiry**: Human-in-the-loop async interaction (approvals, inputs)
- **Identity**: User/service account with RBAC permissions
- **Key**: Encrypted secrets storage
## Key Tools & Libraries
### Shared Dependencies (workspace-level)
- **Async**: tokio, async-trait, futures
- **Web**: axum, tower, tower-http
- **Database**: sqlx (with postgres, json, chrono, uuid features)
- **Serialization**: serde, serde_json, serde_yaml_ng
- **Logging**: tracing, tracing-subscriber
- **Error Handling**: anyhow, thiserror
- **Config**: config crate (YAML + env vars)
- **Validation**: validator
- **Auth**: jsonwebtoken, argon2
- **CLI**: clap
- **OpenAPI**: utoipa, utoipa-swagger-ui
- **Message Queue**: lapin (RabbitMQ)
- **HTTP Client**: reqwest
- **Testing**: mockall, tempfile, serial_test
### Web UI Dependencies
- **Framework**: React 19 + react-router-dom
- **State**: Zustand, @tanstack/react-query
- **HTTP**: axios (with generated OpenAPI client)
- **Styling**: Tailwind CSS
- **Icons**: lucide-react
- **Build**: Vite, TypeScript
## Configuration System
- **Primary**: YAML config files (`config.yaml`, `config.{env}.yaml`)
- **Overrides**: Environment variables with prefix `ATTUNE__` and separator `__`
- Example: `ATTUNE__DATABASE__URL`, `ATTUNE__SERVER__PORT`
- **Loading Priority**: Base config → env-specific config → env vars
- **Required for Production**: `JWT_SECRET`, `ENCRYPTION_KEY` (32+ chars)
- **Location**: Root directory or `ATTUNE_CONFIG` env var path
## Authentication & Security
- **Auth Type**: JWT (access tokens: 1h, refresh tokens: 7d)
- **Password Hashing**: Argon2id
- **Protected Routes**: Use `RequireAuth(user)` extractor in Axum
- **Secrets Storage**: AES-GCM encrypted in `key` table with scoped ownership
- **User Info**: Stored in `identity` table
## Code Conventions & Patterns
### General
- **Error Handling**: Use `attune_common::error::Error` and `Result<T>` type alias
- **Async Everywhere**: All I/O operations use async/await with Tokio
- **Module Structure**: Public API exposed via `mod.rs` with `pub use` re-exports
### Database Layer
- **Schema**: All tables use unqualified names; schema determined by PostgreSQL `search_path`
- **Production**: Always uses `public` schema (configured explicitly in `config.production.yaml`)
- **Tests**: Each test uses isolated schema (e.g., `test_a1b2c3d4`) for true parallel execution
- **Schema Resolution**: PostgreSQL `search_path` mechanism, NO hardcoded schema prefixes in queries
- **Models**: Defined in `common/src/models.rs` with `#[derive(FromRow)]` for SQLx
- **Repositories**: One per entity in `common/src/repositories/`, provides CRUD + specialized queries
- **Pattern**: Services MUST interact with DB only through repository layer (no direct queries)
- **Transactions**: Use SQLx transactions for multi-table operations
- **IDs**: All IDs are `i64` (BIGSERIAL in PostgreSQL)
- **Timestamps**: `created`/`updated` columns auto-managed by DB triggers
- **JSON Fields**: Use `serde_json::Value` for flexible attributes/parameters, including `execution.workflow_task` JSONB
- **Enums**: PostgreSQL enum types mapped with `#[sqlx(type_name = "...")]`
- **Workflow Tasks**: Stored as JSONB in `execution.workflow_task` (consolidated from separate table 2026-01-27)
**Table Count**: 17 tables total in the schema
### Pack File Loading
- **Pack Base Directory**: Configured via `packs_base_dir` in config (defaults to `/opt/attune/packs`, development uses `./packs`)
- **Action Script Resolution**: Worker constructs file paths as `{packs_base_dir}/{pack_ref}/actions/{entrypoint}`
- **Runtime Selection**: Determined by action's runtime field (e.g., "Shell", "Python") - compared case-insensitively
- **Parameter Passing**: Shell actions receive parameters as environment variables with `ATTUNE_ACTION_` prefix
### API Service (`crates/api`)
- **Structure**: `routes/` (endpoints) + `dto/` (request/response) + `auth/` + `middleware/`
- **Responses**: Standardized `ApiResponse<T>` wrapper with `data` field
- **Protected Routes**: Apply `RequireAuth` middleware
- **OpenAPI**: Documented with `utoipa` attributes (`#[utoipa::path]`)
- **Error Handling**: Custom `ApiError` type with proper HTTP status codes
- **Available at**: `http://localhost:8080` (dev), `/api-spec/openapi.json` for spec
### Common Library (`crates/common`)
- **Modules**: `models`, `repositories`, `db`, `config`, `error`, `mq`, `crypto`, `utils`, `workflow`, `pack_registry`
- **Exports**: Commonly used types re-exported from `lib.rs`
- **Repository Layer**: All DB access goes through repositories in `repositories/`
- **Message Queue**: Abstractions in `mq/` for RabbitMQ communication
### Web UI (`web/`)
- **Generated Client**: OpenAPI client auto-generated from API spec
- Run: `npm run generate:api` (requires API running on :8080)
- Location: `src/api/`
- **State Management**: Zustand for global state, TanStack Query for server state
- **Styling**: Tailwind utility classes
- **Dev Server**: `npm run dev` (typically :3000 or :5173)
- **Build**: `npm run build`
## Development Workflow
### Common Commands (Makefile)
```bash
make build # Build all services
make build-release # Release build
make test # Run all tests
make test-integration # Run integration tests
make fmt # Format code
make clippy # Run linter
make lint # fmt + clippy
make run-api # Run API service
make run-executor # Run executor service
make run-worker # Run worker service
make run-sensor # Run sensor service
make run-notifier # Run notifier service
make db-create # Create database
make db-migrate # Run migrations
make db-reset # Drop & recreate DB
```
### Database Operations
- **Migrations**: Located in `migrations/`, applied via `sqlx migrate run`
- **Test DB**: Separate `attune_test` database, setup with `make db-test-setup`
- **Schema**: All tables in `public` schema with auto-updating timestamps
- **Core Pack**: Load with `./scripts/load-core-pack.sh` after DB setup
### Testing
- **Architecture**: Schema-per-test isolation (each test gets unique `test_<uuid>` schema)
- **Parallel Execution**: Tests run concurrently without `#[serial]` constraints (4-8x faster)
- **Unit Tests**: In module files alongside code
- **Integration Tests**: In `tests/` directory
- **Test DB Required**: Use `make db-test-setup` before integration tests
- **Run**: `cargo test` or `make test` (parallel by default)
- **Verbose**: `cargo test -- --nocapture --test-threads=1`
- **Cleanup**: Schemas auto-dropped on test completion; orphaned schemas cleaned via `./scripts/cleanup-test-schemas.sh`
- **SQLx Offline Mode**: Enabled for compile-time query checking without live DB; regenerate with `cargo sqlx prepare`
### CLI Tool
```bash
cargo install --path crates/cli # Install CLI
attune auth login # Login
attune pack list # List packs
attune action execute <ref> --param key=value
attune execution list # Monitor executions
```
## Test Failure Protocol
**Proactively investigate and fix test failures when discovered, even if unrelated to the current task.**
### Guidelines:
- **ALWAYS report test failures** to the user with relevant error output
- **ALWAYS run tests** after making changes: `make test` or `cargo test`
- **DO fix immediately** if the cause is obvious and fixable in 1-2 attempts
- **DO ask the user** if the failure is complex, requires architectural changes, or you're unsure of the cause
- **NEVER silently ignore** test failures or skip tests without approval
- **Gather context**: Run with `cargo test -- --nocapture --test-threads=1` for details
### Priority:
- **Critical** (build/compile failures): Fix immediately
- **Related** (affects current work): Fix before proceeding
- **Unrelated**: Report and ask if you should fix now or defer
When reporting, ask: "Should I fix this first or continue with [original task]?"
## Code Quality: Zero Warnings Policy
**Maintain zero compiler warnings across the workspace.** Clean builds ensure new issues are immediately visible.
### Workflow
- **Check after changes:** `cargo check --all-targets --workspace`
- **Before completing work:** Fix or document any warnings introduced
- **End of session:** Verify zero warnings before finishing
### Handling Warnings
- **Fix first:** Remove dead code, unused imports, unnecessary variables
- **Prefix `_`:** For intentionally unused variables that document intent
- **Use `#[allow(dead_code)]`:** For API methods intended for future use (add doc comment explaining why)
- **Never ignore blindly:** Every suppression needs a clear rationale
### Conservative Approach
- Preserve methods that complete a logical API surface
- Keep test helpers that are part of shared infrastructure
- When uncertain about removal, ask the user
### Red Flags
- ❌ Introducing new warnings
- ❌ Blanket `#[allow(warnings)]` without specific justification
- ❌ Accumulating warnings over time
## File Naming & Location Conventions
### When Adding Features:
- **New API Endpoint**:
- Route handler in `crates/api/src/routes/<domain>.rs`
- DTO in `crates/api/src/dto/<domain>.rs`
- Update `routes/mod.rs` and main router
- **New Domain Model**:
- Add to `crates/common/src/models.rs`
- Create migration in `migrations/YYYYMMDDHHMMSS_description.sql`
- Add repository in `crates/common/src/repositories/<entity>.rs`
- **New Service**: Add to `crates/` and update workspace `Cargo.toml` members
- **Configuration**: Update `crates/common/src/config.rs` with serde defaults
- **Documentation**: Add to `docs/` directory
### Important Files
- `crates/common/src/models.rs` - All domain models
- `crates/common/src/error.rs` - Error types
- `crates/common/src/config.rs` - Configuration structure
- `crates/api/src/routes/mod.rs` - API routing
- `config.development.yaml` - Dev configuration
- `Cargo.toml` - Workspace dependencies
- `Makefile` - Development commands
## Common Pitfalls to Avoid
1. **NEVER** bypass repositories - always use the repository layer for DB access
2. **NEVER** forget `RequireAuth` middleware on protected endpoints
3. **NEVER** hardcode service URLs - use configuration
4. **NEVER** commit secrets in config files (use env vars in production)
5. **NEVER** hardcode schema prefixes in SQL queries - rely on PostgreSQL `search_path` mechanism
6. **ALWAYS** use PostgreSQL enum type mappings for custom enums
7. **ALWAYS** use transactions for multi-table operations
8. **ALWAYS** start with `attune/` or correct crate name when specifying file paths
9. **ALWAYS** convert runtime names to lowercase for comparison (database may store capitalized)
10. **REMEMBER** IDs are `i64`, not `i32` or `uuid`
11. **REMEMBER** schema is determined by `search_path`, not hardcoded in queries (production uses `attune`, development uses `public`)
12. **REMEMBER** to regenerate SQLx metadata after schema-related changes: `cargo sqlx prepare`
## Deployment
- **Target**: Distributed deployment with separate service instances
- **Docker**: Dockerfiles for each service (planned in `docker/` dir)
- **Config**: Use environment variables for secrets in production
- **Database**: PostgreSQL 14+ with connection pooling
- **Message Queue**: RabbitMQ required for service communication
- **Web UI**: Static files served separately or via API service
## Current Development Status
- ✅ **Complete**: Database migrations (17 tables), API service (most endpoints), common library, message queue infrastructure, repository layer, JWT auth, CLI tool, Web UI (basic), Executor service (core functionality), Worker service (shell/Python execution)
- 🔄 **In Progress**: Sensor service, advanced workflow features, Python runtime dependency management
- 📋 **Planned**: Notifier service, execution policies, monitoring, pack registry system
## Quick Reference
### Start Development Environment
```bash
# Start PostgreSQL and RabbitMQ
# Load core pack: ./scripts/load-core-pack.sh
# Start API: make run-api
# Start Web UI: cd web && npm run dev
```
### File Path Examples
- Models: `attune/crates/common/src/models.rs`
- API routes: `attune/crates/api/src/routes/actions.rs`
- Repositories: `attune/crates/common/src/repositories/execution.rs`
- Migrations: `attune/migrations/*.sql`
- Web UI: `attune/web/src/`
- Config: `attune/config.development.yaml`
### Documentation Locations
- API docs: `attune/docs/api-*.md`
- Configuration: `attune/docs/configuration.md`
- Architecture: `attune/docs/*-architecture.md`, `attune/docs/*-service.md`
- Testing: `attune/docs/testing-*.md`, `attune/docs/running-tests.md`, `attune/docs/schema-per-test.md`
- AI Agent Work Summaries: `attune/work-summary/*.md`
- Deployment: `attune/docs/production-deployment.md`
- DO NOT create additional documentation files in the root of the project. all new documentation describing how to use the system should be placed in the `attune/docs` directory, and documentation describing the work performed should be placed in the `attune/work-summary` directory.
## Work Summary & Reporting
**Avoid redundant summarization - summarize changes once at completion, not continuously.**
### Guidelines:
- **Report progress** during work: brief status updates, blockers, questions
- **Summarize once** at completion: consolidated overview of all changes made
- **Work summaries**: Write to `attune/work-summary/*.md` only at task completion, not incrementally
- **Avoid duplication**: Don't re-explain the same changes multiple times in different formats
- **What changed, not how**: Focus on outcomes and impacts, not play-by-play narration
### Good Pattern:
```
[Making changes with tool calls and brief progress notes]
...
[At completion]
"I've completed the task. Here's a summary of changes: [single consolidated overview]"
```
### Bad Pattern:
```
[Makes changes]
"So I changed X, Y, and Z..."
[More changes]
"To summarize, I modified X, Y, and Z..."
[Writes work summary]
"In this session I updated X, Y, and Z..."
```
## Maintaining the AGENTS.md file
**IMPORTANT: Keep this file up-to-date as the project evolves.**
After making changes to the project, you MUST update this `AGENTS.md` file if any of the following occur:
- **New dependencies added or major dependencies removed** (check package.json, Cargo.toml, requirements.txt, etc.)
- **Project structure changes**: new directories/modules created, existing ones renamed or removed
- **Architecture changes**: new layers, patterns, or major refactoring that affects how components interact
- **New frameworks or tools adopted** (e.g., switching from REST to GraphQL, adding a new testing framework)
- **Deployment or infrastructure changes** (new CI/CD pipelines, different hosting, containerization added)
- **New major features** that introduce new subsystems or significantly change existing ones
- **Style guide or coding convention updates**
### `AGENTS.md` Content inclusion policy
- DO NOT simply summarize changes in the `AGENTS.md` file. If there are existing sections that need updating due to changes in the application architecture or project structure, update them accordingly.
- When relevant, work summaries should instead be written to `attune/work-summary/*.md`
### Update procedure:
1. After completing your changes, review if they affect any section of `AGENTS.md`
2. If yes, immediately update the relevant sections
3. Add a brief comment at the top of `AGENTS.md` with the date and what was updated (optional but helpful)
### Update format:
When updating, be surgical - modify only the affected sections rather than rewriting the entire file. Maintain the existing structure and tone.
**Treat `AGENTS.md` as living documentation.** An outdated `AGENTS.md` file is worse than no `AGENTS.md` file, as it will mislead future AI agents and waste time.
## Project Documentation Index
{{DOCUMENTATION_INDEX}}

7179
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -14,14 +14,14 @@ members = [
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
authors = ["Attune Team"] authors = ["David Culbreth"]
license = "MIT" license = "Apache-2.0"
repository = "https://github.com/yourusername/attune" repository = "https://git.rdrx.app/attune-system/attune"
[workspace.dependencies] [workspace.dependencies]
# Async runtime # Async runtime
tokio = { version = "1.42", features = ["full"] } tokio = { version = "1.50", features = ["full"] }
tokio-util = "0.7" tokio-util = { version = "0.7", features = ["io"] }
tokio-stream = { version = "0.1", features = ["sync"] } tokio-stream = { version = "0.1", features = ["sync"] }
# Web framework # Web framework
@@ -52,29 +52,31 @@ config = "0.15"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
# UUID # UUID
uuid = { version = "1.11", features = ["v4", "serde"] } uuid = { version = "1.22", features = ["v4", "serde"] }
# Validation # Validation
validator = { version = "0.20", features = ["derive"] } validator = { version = "0.20", features = ["derive"] }
# CLI # CLI
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.6", features = ["derive"] }
# Message queue / PubSub # Message queue / PubSub
# RabbitMQ # RabbitMQ
lapin = "3.7" lapin = "4.3"
# Redis # Redis
redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] } redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] }
# JSON Schema # JSON Schema
schemars = { version = "1.2", features = ["chrono04"] } schemars = { version = "1.2", features = ["chrono04"] }
jsonschema = "0.38" jsonschema = "0.44"
# OpenAPI/Swagger # OpenAPI/Swagger
utoipa = { version = "5.4", features = ["chrono", "uuid"] } utoipa = { version = "5.4", features = ["chrono", "uuid"] }
# JWT # JWT
jsonwebtoken = { version = "10.2", features = ["rust_crypto"] } jsonwebtoken = { version = "10.3", features = ["hmac", "sha2"] }
hmac = "0.12"
signature = "2.2"
# Encryption # Encryption
argon2 = "0.5" argon2 = "0.5"
@@ -84,22 +86,22 @@ aes-gcm = "0.10"
sha2 = "0.10" sha2 = "0.10"
# Regular expressions # Regular expressions
regex = "1.11" regex = "1.12"
# HTTP client # HTTP client
reqwest = { version = "0.13", features = ["json"] } reqwest = { version = "0.13", features = ["json"] }
reqwest-eventsource = "0.6" reqwest-eventsource = "0.6"
hyper = { version = "1.0", features = ["full"] } hyper = { version = "1.8", features = ["full"] }
# File system utilities # File system utilities
walkdir = "2.4" walkdir = "2.5"
# Archive/compression # Archive/compression
tar = "0.4" tar = "0.4"
flate2 = "1.0" flate2 = "1.1"
# WebSocket client # WebSocket client
tokio-tungstenite = { version = "0.26", features = ["native-tls"] } tokio-tungstenite = { version = "0.28", features = ["native-tls"] }
# URL parsing # URL parsing
url = "2.5" url = "2.5"
@@ -112,11 +114,11 @@ futures = "0.3"
semver = { version = "1.0", features = ["serde"] } semver = { version = "1.0", features = ["serde"] }
# Temp files # Temp files
tempfile = "3.8" tempfile = "3.27"
# Testing # Testing
mockall = "0.14" mockall = "0.14"
serial_test = "3.2" serial_test = "3.4"
# Concurrent data structures # Concurrent data structures
dashmap = "6.1" dashmap = "6.1"

202
LICENSE Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

109
Makefile
View File

@@ -3,7 +3,10 @@
docker-up docker-down docker-cache-warm docker-stop-system-services dev watch generate-agents-index \ docker-up docker-down docker-cache-warm docker-stop-system-services dev watch generate-agents-index \
docker-build-workers docker-build-worker-base docker-build-worker-python \ docker-build-workers docker-build-worker-base docker-build-worker-python \
docker-build-worker-node docker-build-worker-full deny ci-rust ci-web-blocking ci-web-advisory \ docker-build-worker-node docker-build-worker-full deny ci-rust ci-web-blocking ci-web-advisory \
ci-security-blocking ci-security-advisory ci-blocking ci-advisory ci-security-blocking ci-security-advisory ci-blocking ci-advisory \
fmt-check pre-commit install-git-hooks \
build-agent docker-build-agent run-agent run-agent-release \
docker-up-agent docker-down-agent
# Default target # Default target
help: help:
@@ -19,13 +22,18 @@ help:
@echo " make test - Run all tests" @echo " make test - Run all tests"
@echo " make test-common - Run tests for common library" @echo " make test-common - Run tests for common library"
@echo " make test-api - Run tests for API service" @echo " make test-api - Run tests for API service"
@echo " make test-integration - Run integration tests" @echo " make test-integration - Run integration tests (common + API)"
@echo " make test-integration-api - Run API integration tests (requires DB)"
@echo " make check - Check code without building" @echo " make check - Check code without building"
@echo "" @echo ""
@echo "Code Quality:" @echo "Code Quality:"
@echo " make fmt - Format all code" @echo " make fmt - Format all code"
@echo " make fmt-check - Verify formatting without changing files"
@echo " make clippy - Run linter" @echo " make clippy - Run linter"
@echo " make lint - Run both fmt and clippy" @echo " make lint - Run both fmt and clippy"
@echo " make deny - Run cargo-deny checks"
@echo " make pre-commit - Run the git pre-commit checks locally"
@echo " make install-git-hooks - Configure git to use the repo hook scripts"
@echo "" @echo ""
@echo "Running Services:" @echo "Running Services:"
@echo " make run-api - Run API service" @echo " make run-api - Run API service"
@@ -54,6 +62,14 @@ help:
@echo " make docker-up - Start services with docker compose" @echo " make docker-up - Start services with docker compose"
@echo " make docker-down - Stop services" @echo " make docker-down - Stop services"
@echo "" @echo ""
@echo "Agent (Universal Worker):"
@echo " make build-agent - Build statically-linked agent binary (musl)"
@echo " make docker-build-agent - Build agent Docker image"
@echo " make run-agent - Run agent in development mode"
@echo " make run-agent-release - Run agent in release mode"
@echo " make docker-up-agent - Start all services + agent workers (ruby, etc.)"
@echo " make docker-down-agent - Stop agent stack"
@echo ""
@echo "Development:" @echo "Development:"
@echo " make watch - Watch and rebuild on changes" @echo " make watch - Watch and rebuild on changes"
@echo " make install-tools - Install development tools" @echo " make install-tools - Install development tools"
@@ -63,7 +79,7 @@ help:
@echo "" @echo ""
# Increase rustc stack size to prevent SIGSEGV during compilation # Increase rustc stack size to prevent SIGSEGV during compilation
export RUST_MIN_STACK := 16777216 export RUST_MIN_STACK:=67108864
# Building # Building
build: build:
@@ -88,13 +104,18 @@ test-api:
test-verbose: test-verbose:
cargo test -- --nocapture --test-threads=1 cargo test -- --nocapture --test-threads=1
test-integration: test-integration: test-integration-api
@echo "Setting up test database..." @echo "Setting up test database..."
@make db-test-setup @make db-test-setup
@echo "Running integration tests..." @echo "Running common integration tests..."
cargo test --test '*' -p attune-common -- --test-threads=1 cargo test --test '*' -p attune-common -- --test-threads=1
@echo "Integration tests complete" @echo "Integration tests complete"
test-integration-api:
@echo "Running API integration tests..."
cargo test -p attune-api -- --ignored --test-threads=1
@echo "API integration tests complete"
test-with-db: db-test-setup test-integration test-with-db: db-test-setup test-integration
@echo "All tests with database complete" @echo "All tests with database complete"
@@ -105,6 +126,9 @@ check:
fmt: fmt:
cargo fmt --all cargo fmt --all
fmt-check:
cargo fmt --all -- --check
clippy: clippy:
cargo clippy --all-features -- -D warnings cargo clippy --all-features -- -D warnings
@@ -213,38 +237,53 @@ docker-build-api:
docker-build-web: docker-build-web:
docker compose build web docker compose build web
# Build worker images # Agent binary (statically-linked for injection into any container)
docker-build-workers: docker-build-worker-base docker-build-worker-python docker-build-worker-node docker-build-worker-full build-agent:
@echo "✅ All worker images built successfully" @echo "Installing musl target (if not already installed)..."
rustup target add x86_64-unknown-linux-musl 2>/dev/null || true
@echo "Building statically-linked worker and sensor agent binaries..."
SQLX_OFFLINE=true cargo build --release --target x86_64-unknown-linux-musl --bin attune-agent --bin attune-sensor-agent
strip target/x86_64-unknown-linux-musl/release/attune-agent
strip target/x86_64-unknown-linux-musl/release/attune-sensor-agent
@echo "✅ Agent binaries built:"
@echo " - target/x86_64-unknown-linux-musl/release/attune-agent"
@echo " - target/x86_64-unknown-linux-musl/release/attune-sensor-agent"
@ls -lh target/x86_64-unknown-linux-musl/release/attune-agent
@ls -lh target/x86_64-unknown-linux-musl/release/attune-sensor-agent
docker-build-worker-base: docker-build-agent:
@echo "Building base worker (shell only)..." @echo "Building agent Docker image (statically-linked binary)..."
DOCKER_BUILDKIT=1 docker build --target worker-base -t attune-worker:base -f docker/Dockerfile.worker . DOCKER_BUILDKIT=1 docker buildx build --target agent-init -f docker/Dockerfile.agent -t attune-agent:latest .
@echo "✅ Base worker image built: attune-worker:base" @echo "✅ Agent image built: attune-agent:latest"
docker-build-worker-python: run-agent:
@echo "Building Python worker (shell + python)..." cargo run --bin attune-agent
DOCKER_BUILDKIT=1 docker build --target worker-python -t attune-worker:python -f docker/Dockerfile.worker .
@echo "✅ Python worker image built: attune-worker:python"
docker-build-worker-node: run-agent-release:
@echo "Building Node.js worker (shell + node)..." cargo run --bin attune-agent --release
DOCKER_BUILDKIT=1 docker build --target worker-node -t attune-worker:node -f docker/Dockerfile.worker .
@echo "✅ Node.js worker image built: attune-worker:node"
docker-build-worker-full: run-sensor-agent:
@echo "Building full worker (all runtimes)..." cargo run --bin attune-sensor-agent
DOCKER_BUILDKIT=1 docker build --target worker-full -t attune-worker:full -f docker/Dockerfile.worker .
@echo "✅ Full worker image built: attune-worker:full" run-sensor-agent-release:
cargo run --bin attune-sensor-agent --release
docker-up: docker-up:
@echo "Starting all services with Docker Compose..." @echo "Starting all services with Docker Compose..."
docker compose up -d docker compose up -d
docker-up-agent:
@echo "Starting all services + agent-based workers..."
docker compose -f docker-compose.yaml -f docker-compose.agent.yaml up -d
docker-down: docker-down:
@echo "Stopping all services..." @echo "Stopping all services..."
docker compose down docker compose down
docker-down-agent:
@echo "Stopping all services (including agent workers)..."
docker compose -f docker-compose.yaml -f docker-compose.agent.yaml down
docker-down-volumes: docker-down-volumes:
@echo "Stopping all services and removing volumes (WARNING: deletes data)..." @echo "Stopping all services and removing volumes (WARNING: deletes data)..."
docker compose down -v docker compose down -v
@@ -316,9 +355,9 @@ coverage:
update: update:
cargo update cargo update
# Audit dependencies for security issues # Audit dependencies for security issues (ignores configured in deny.toml)
audit: audit:
cargo audit cargo deny check advisories
deny: deny:
cargo deny check cargo deny check
@@ -327,7 +366,6 @@ ci-rust:
cargo fmt --all -- --check cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features cargo test --workspace --all-features
cargo audit
cargo deny check cargo deny check
ci-web-blocking: ci-web-blocking:
@@ -336,6 +374,11 @@ ci-web-blocking:
cd web && npm run typecheck cd web && npm run typecheck
cd web && npm run build cd web && npm run build
ci-web-pre-commit:
cd web && npm ci
cd web && npm run lint
cd web && npm run typecheck
ci-web-advisory: ci-web-advisory:
cd web && npm ci cd web && npm ci
cd web && npm run knip cd web && npm run knip
@@ -376,9 +419,15 @@ licenses:
cargo license --json > licenses.json cargo license --json > licenses.json
@echo "License information saved to licenses.json" @echo "License information saved to licenses.json"
# All-in-one check before committing # Blocking checks run by the git pre-commit hook after formatting.
pre-commit: fmt clippy test # Keep the local web step fast; full production builds stay in CI.
@echo "✅ All checks passed! Ready to commit." pre-commit: deny ci-web-pre-commit ci-security-blocking
@echo "✅ Pre-commit checks passed."
install-git-hooks:
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit
@echo "✅ Git hooks configured to use .githooks/"
# CI simulation # CI simulation
ci: ci-blocking ci-advisory ci: ci-blocking ci-advisory

6
charts/attune/Chart.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: attune
description: Helm chart for deploying the Attune automation platform
type: application
version: 0.1.0
appVersion: "0.1.0"

View File

@@ -0,0 +1,26 @@
1. Set `global.imageRegistry`, `global.imageNamespace`, and `global.imageTag` so the chart pulls the images published by the Gitea workflow.
2. Set `web.config.apiUrl` and `web.config.wsUrl` to browser-reachable endpoints before exposing the web UI.
3. The shared `packs`, `runtime_envs`, and `artifacts` PVCs default to `ReadWriteMany`; your cluster storage class must support RWX or you need to override those claims.
{{- if .Values.agentWorkers }}
Agent-based workers enabled:
{{- range .Values.agentWorkers }}
- {{ .name }}: image={{ .image }}, replicas={{ .replicas | default 1 }}
{{- if .runtimes }} runtimes={{ join "," .runtimes }}{{ else }} runtimes=auto-detect{{ end }}
{{- end }}
Each agent worker uses an init container to copy the statically-linked
attune-agent binary into the worker pod via an emptyDir volume. The agent
auto-detects available runtimes in the container and registers with Attune.
The default sensor deployment also uses the same injection pattern, copying
`attune-sensor-agent` into the pod before starting a stock runtime image.
To add more agent workers, append entries to `agentWorkers` in your values:
agentWorkers:
- name: my-runtime
image: my-org/my-image:latest
replicas: 1
runtimes: [] # auto-detect
{{- end }}

View File

@@ -0,0 +1,113 @@
{{- define "attune.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "attune.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name (include "attune.name" .) | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- define "attune.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" -}}
{{- end -}}
{{- define "attune.labels" -}}
helm.sh/chart: {{ include "attune.chart" . }}
app.kubernetes.io/name: {{ include "attune.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "attune.selectorLabels" -}}
app.kubernetes.io/name: {{ include "attune.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "attune.componentLabels" -}}
{{ include "attune.selectorLabels" .root }}
app.kubernetes.io/component: {{ .component }}
{{- end -}}
{{- define "attune.image" -}}
{{- $root := .root -}}
{{- $image := .image -}}
{{- $registry := $root.Values.global.imageRegistry -}}
{{- $namespace := $root.Values.global.imageNamespace -}}
{{- $repository := $image.repository -}}
{{- $tag := default $root.Values.global.imageTag $image.tag -}}
{{- if and $registry $namespace -}}
{{- printf "%s/%s/%s:%s" $registry $namespace $repository $tag -}}
{{- else if $registry -}}
{{- printf "%s/%s:%s" $registry $repository $tag -}}
{{- else -}}
{{- printf "%s:%s" $repository $tag -}}
{{- end -}}
{{- end -}}
{{- define "attune.secretName" -}}
{{- if .Values.security.existingSecret -}}
{{- .Values.security.existingSecret -}}
{{- else -}}
{{- printf "%s-secrets" (include "attune.fullname" .) -}}
{{- end -}}
{{- end -}}
{{- define "attune.postgresqlServiceName" -}}
{{- if .Values.database.host -}}
{{- .Values.database.host -}}
{{- else -}}
{{- printf "%s-postgresql" (include "attune.fullname" .) -}}
{{- end -}}
{{- end -}}
{{- define "attune.rabbitmqServiceName" -}}
{{- if .Values.rabbitmq.host -}}
{{- .Values.rabbitmq.host -}}
{{- else -}}
{{- printf "%s-rabbitmq" (include "attune.fullname" .) -}}
{{- end -}}
{{- end -}}
{{- define "attune.redisServiceName" -}}
{{- if .Values.redis.host -}}
{{- .Values.redis.host -}}
{{- else -}}
{{- printf "%s-redis" (include "attune.fullname" .) -}}
{{- end -}}
{{- end -}}
{{- define "attune.databaseUrl" -}}
{{- if .Values.database.url -}}
{{- .Values.database.url -}}
{{- else -}}
{{- printf "postgresql://%s:%s@%s:%v/%s" .Values.database.username .Values.database.password (include "attune.postgresqlServiceName" .) .Values.database.port .Values.database.database -}}
{{- end -}}
{{- end -}}
{{- define "attune.rabbitmqUrl" -}}
{{- if .Values.rabbitmq.url -}}
{{- .Values.rabbitmq.url -}}
{{- else -}}
{{- printf "amqp://%s:%s@%s:%v" .Values.rabbitmq.username .Values.rabbitmq.password (include "attune.rabbitmqServiceName" .) .Values.rabbitmq.port -}}
{{- end -}}
{{- end -}}
{{- define "attune.redisUrl" -}}
{{- if .Values.redis.url -}}
{{- .Values.redis.url -}}
{{- else -}}
{{- printf "redis://%s:%v" (include "attune.redisServiceName" .) .Values.redis.port -}}
{{- end -}}
{{- end -}}
{{- define "attune.apiServiceName" -}}
{{- printf "%s-api" (include "attune.fullname" .) -}}
{{- end -}}
{{- define "attune.notifierServiceName" -}}
{{- printf "%s-notifier" (include "attune.fullname" .) -}}
{{- end -}}

View File

@@ -0,0 +1,137 @@
{{- range .Values.agentWorkers }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "attune.fullname" $ }}-agent-worker-{{ .name }}
labels:
{{- include "attune.labels" $ | nindent 4 }}
app.kubernetes.io/component: agent-worker-{{ .name }}
spec:
replicas: {{ .replicas | default 1 }}
selector:
matchLabels:
{{- include "attune.selectorLabels" $ | nindent 6 }}
app.kubernetes.io/component: agent-worker-{{ .name }}
template:
metadata:
labels:
{{- include "attune.selectorLabels" $ | nindent 8 }}
app.kubernetes.io/component: agent-worker-{{ .name }}
spec:
{{- if $.Values.global.imagePullSecrets }}
imagePullSecrets:
{{- toYaml $.Values.global.imagePullSecrets | nindent 8 }}
{{- end }}
{{- if .runtimeClassName }}
runtimeClassName: {{ .runtimeClassName }}
{{- end }}
{{- if .nodeSelector }}
nodeSelector:
{{- toYaml .nodeSelector | nindent 8 }}
{{- end }}
{{- if .tolerations }}
tolerations:
{{- toYaml .tolerations | nindent 8 }}
{{- end }}
{{- if .stopGracePeriod }}
terminationGracePeriodSeconds: {{ .stopGracePeriod }}
{{- else }}
terminationGracePeriodSeconds: 45
{{- end }}
initContainers:
- name: agent-loader
image: {{ include "attune.image" (dict "root" $ "image" $.Values.images.agent) }}
imagePullPolicy: {{ $.Values.images.agent.pullPolicy }}
command: ["cp", "/usr/local/bin/attune-agent", "/opt/attune/agent/attune-agent"]
volumeMounts:
- name: agent-bin
mountPath: /opt/attune/agent
- name: wait-for-schema
image: postgres:16-alpine
command: ["/bin/sh", "-ec"]
args:
- |
until PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -tAc "SELECT to_regclass('${DB_SCHEMA}.identity')" | grep -q identity; do
echo "waiting for schema";
sleep 2;
done
envFrom:
- secretRef:
name: {{ include "attune.secretName" $ }}
- name: wait-for-packs
image: busybox:1.36
command: ["/bin/sh", "-ec"]
args:
- |
until [ -f /opt/attune/packs/core/pack.yaml ]; do
echo "waiting for packs";
sleep 2;
done
volumeMounts:
- name: packs
mountPath: /opt/attune/packs
containers:
- name: worker
image: {{ .image }}
{{- if .imagePullPolicy }}
imagePullPolicy: {{ .imagePullPolicy }}
{{- end }}
command: ["/opt/attune/agent/attune-agent"]
envFrom:
- secretRef:
name: {{ include "attune.secretName" $ }}
env:
- name: ATTUNE_CONFIG
value: /opt/attune/config.yaml
- name: ATTUNE__DATABASE__SCHEMA
value: {{ $.Values.database.schema | quote }}
- name: ATTUNE_WORKER_TYPE
value: container
- name: ATTUNE_WORKER_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: ATTUNE_API_URL
value: http://{{ include "attune.apiServiceName" $ }}:{{ $.Values.api.service.port }}
- name: RUST_LOG
value: {{ .logLevel | default "info" }}
{{- if .runtimes }}
- name: ATTUNE_WORKER_RUNTIMES
value: {{ join "," .runtimes | quote }}
{{- end }}
{{- if .env }}
{{- toYaml .env | nindent 12 }}
{{- end }}
resources:
{{- toYaml (.resources | default dict) | nindent 12 }}
volumeMounts:
- name: agent-bin
mountPath: /opt/attune/agent
readOnly: true
- name: config
mountPath: /opt/attune/config.yaml
subPath: config.yaml
- name: packs
mountPath: /opt/attune/packs
readOnly: true
- name: runtime-envs
mountPath: /opt/attune/runtime_envs
- name: artifacts
mountPath: /opt/attune/artifacts
volumes:
- name: agent-bin
emptyDir: {}
- name: config
configMap:
name: {{ include "attune.fullname" $ }}-config
- name: packs
persistentVolumeClaim:
claimName: {{ include "attune.fullname" $ }}-packs
- name: runtime-envs
persistentVolumeClaim:
claimName: {{ include "attune.fullname" $ }}-runtime-envs
- name: artifacts
persistentVolumeClaim:
claimName: {{ include "attune.fullname" $ }}-artifacts
{{- end }}

View File

@@ -0,0 +1,542 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "attune.apiServiceName" . }}
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
type: {{ .Values.api.service.type }}
selector:
{{- include "attune.componentLabels" (dict "root" . "component" "api") | nindent 4 }}
ports:
- name: http
port: {{ .Values.api.service.port }}
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "attune.apiServiceName" . }}
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.api.replicaCount }}
selector:
matchLabels:
{{- include "attune.componentLabels" (dict "root" . "component" "api") | nindent 6 }}
template:
metadata:
labels:
{{- include "attune.componentLabels" (dict "root" . "component" "api") | nindent 8 }}
spec:
{{- if .Values.global.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.global.imagePullSecrets | nindent 8 }}
{{- end }}
initContainers:
- name: wait-for-schema
image: postgres:16-alpine
command: ["/bin/sh", "-ec"]
args:
- |
until PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -tAc "SELECT to_regclass('${DB_SCHEMA}.identity')" | grep -q identity; do
echo "waiting for schema";
sleep 2;
done
envFrom:
- secretRef:
name: {{ include "attune.secretName" . }}
- name: wait-for-packs
image: busybox:1.36
command: ["/bin/sh", "-ec"]
args:
- |
until [ -f /opt/attune/packs/core/pack.yaml ]; do
echo "waiting for packs";
sleep 2;
done
volumeMounts:
- name: packs
mountPath: /opt/attune/packs
containers:
- name: api
image: {{ include "attune.image" (dict "root" . "image" .Values.images.api) }}
imagePullPolicy: {{ .Values.images.api.pullPolicy }}
envFrom:
- secretRef:
name: {{ include "attune.secretName" . }}
env:
- name: ATTUNE_CONFIG
value: /opt/attune/config.yaml
- name: ATTUNE__DATABASE__SCHEMA
value: {{ .Values.database.schema | quote }}
- name: ATTUNE__WORKER__WORKER_TYPE
value: container
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 20
periodSeconds: 15
resources:
{{- toYaml .Values.api.resources | nindent 12 }}
volumeMounts:
- name: config
mountPath: /opt/attune/config.yaml
subPath: config.yaml
- name: packs
mountPath: /opt/attune/packs
- name: runtime-envs
mountPath: /opt/attune/runtime_envs
- name: artifacts
mountPath: /opt/attune/artifacts
volumes:
- name: config
configMap:
name: {{ include "attune.fullname" . }}-config
- name: packs
persistentVolumeClaim:
claimName: {{ include "attune.fullname" . }}-packs
- name: runtime-envs
persistentVolumeClaim:
claimName: {{ include "attune.fullname" . }}-runtime-envs
- name: artifacts
persistentVolumeClaim:
claimName: {{ include "attune.fullname" . }}-artifacts
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "attune.fullname" . }}-executor
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.executor.replicaCount }}
selector:
matchLabels:
{{- include "attune.componentLabels" (dict "root" . "component" "executor") | nindent 6 }}
template:
metadata:
labels:
{{- include "attune.componentLabels" (dict "root" . "component" "executor") | nindent 8 }}
spec:
{{- if .Values.global.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.global.imagePullSecrets | nindent 8 }}
{{- end }}
initContainers:
- name: wait-for-schema
image: postgres:16-alpine
command: ["/bin/sh", "-ec"]
args:
- |
until PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -tAc "SELECT to_regclass('${DB_SCHEMA}.identity')" | grep -q identity; do
echo "waiting for schema";
sleep 2;
done
envFrom:
- secretRef:
name: {{ include "attune.secretName" . }}
- name: wait-for-packs
image: busybox:1.36
command: ["/bin/sh", "-ec"]
args:
- |
until [ -f /opt/attune/packs/core/pack.yaml ]; do
echo "waiting for packs";
sleep 2;
done
volumeMounts:
- name: packs
mountPath: /opt/attune/packs
containers:
- name: executor
image: {{ include "attune.image" (dict "root" . "image" .Values.images.executor) }}
imagePullPolicy: {{ .Values.images.executor.pullPolicy }}
envFrom:
- secretRef:
name: {{ include "attune.secretName" . }}
env:
- name: ATTUNE_CONFIG
value: /opt/attune/config.yaml
- name: ATTUNE__DATABASE__SCHEMA
value: {{ .Values.database.schema | quote }}
- name: ATTUNE__WORKER__WORKER_TYPE
value: container
resources:
{{- toYaml .Values.executor.resources | nindent 12 }}
volumeMounts:
- name: config
mountPath: /opt/attune/config.yaml
subPath: config.yaml
- name: packs
mountPath: /opt/attune/packs
- name: artifacts
mountPath: /opt/attune/artifacts
volumes:
- name: config
configMap:
name: {{ include "attune.fullname" . }}-config
- name: packs
persistentVolumeClaim:
claimName: {{ include "attune.fullname" . }}-packs
- name: artifacts
persistentVolumeClaim:
claimName: {{ include "attune.fullname" . }}-artifacts
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "attune.fullname" . }}-worker
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.worker.replicaCount }}
selector:
matchLabels:
{{- include "attune.componentLabels" (dict "root" . "component" "worker") | nindent 6 }}
template:
metadata:
labels:
{{- include "attune.componentLabels" (dict "root" . "component" "worker") | nindent 8 }}
spec:
{{- if .Values.global.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.global.imagePullSecrets | nindent 8 }}
{{- end }}
initContainers:
- name: wait-for-schema
image: postgres:16-alpine
command: ["/bin/sh", "-ec"]
args:
- |
until PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -tAc "SELECT to_regclass('${DB_SCHEMA}.identity')" | grep -q identity; do
echo "waiting for schema";
sleep 2;
done
envFrom:
- secretRef:
name: {{ include "attune.secretName" . }}
- name: wait-for-packs
image: busybox:1.36
command: ["/bin/sh", "-ec"]
args:
- |
until [ -f /opt/attune/packs/core/pack.yaml ]; do
echo "waiting for packs";
sleep 2;
done
volumeMounts:
- name: packs
mountPath: /opt/attune/packs
containers:
- name: worker
image: {{ include "attune.image" (dict "root" . "image" .Values.images.worker) }}
imagePullPolicy: {{ .Values.images.worker.pullPolicy }}
envFrom:
- secretRef:
name: {{ include "attune.secretName" . }}
env:
- name: ATTUNE_CONFIG
value: /opt/attune/config.yaml
- name: ATTUNE__DATABASE__SCHEMA
value: {{ .Values.database.schema | quote }}
- name: ATTUNE_WORKER_RUNTIMES
value: {{ .Values.worker.runtimes | quote }}
- name: ATTUNE_WORKER_TYPE
value: container
- name: ATTUNE_WORKER_NAME
value: {{ .Values.worker.name | quote }}
- name: ATTUNE_API_URL
value: http://{{ include "attune.apiServiceName" . }}:{{ .Values.api.service.port }}
resources:
{{- toYaml .Values.worker.resources | nindent 12 }}
volumeMounts:
- name: config
mountPath: /opt/attune/config.yaml
subPath: config.yaml
- name: packs
mountPath: /opt/attune/packs
- name: runtime-envs
mountPath: /opt/attune/runtime_envs
- name: artifacts
mountPath: /opt/attune/artifacts
volumes:
- name: config
configMap:
name: {{ include "attune.fullname" . }}-config
- name: packs
persistentVolumeClaim:
claimName: {{ include "attune.fullname" . }}-packs
- name: runtime-envs
persistentVolumeClaim:
claimName: {{ include "attune.fullname" . }}-runtime-envs
- name: artifacts
persistentVolumeClaim:
claimName: {{ include "attune.fullname" . }}-artifacts
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "attune.fullname" . }}-sensor
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.sensor.replicaCount }}
selector:
matchLabels:
{{- include "attune.componentLabels" (dict "root" . "component" "sensor") | nindent 6 }}
template:
metadata:
labels:
{{- include "attune.componentLabels" (dict "root" . "component" "sensor") | nindent 8 }}
spec:
{{- if .Values.global.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.global.imagePullSecrets | nindent 8 }}
{{- end }}
terminationGracePeriodSeconds: 45
initContainers:
- name: sensor-agent-loader
image: {{ include "attune.image" (dict "root" . "image" .Values.images.agent) }}
imagePullPolicy: {{ .Values.images.agent.pullPolicy }}
command: ["cp", "/usr/local/bin/attune-sensor-agent", "/opt/attune/agent/attune-sensor-agent"]
volumeMounts:
- name: agent-bin
mountPath: /opt/attune/agent
- name: wait-for-schema
image: postgres:16-alpine
command: ["/bin/sh", "-ec"]
args:
- |
until PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -tAc "SELECT to_regclass('${DB_SCHEMA}.identity')" | grep -q identity; do
echo "waiting for schema";
sleep 2;
done
envFrom:
- secretRef:
name: {{ include "attune.secretName" . }}
- name: wait-for-packs
image: busybox:1.36
command: ["/bin/sh", "-ec"]
args:
- |
until [ -f /opt/attune/packs/core/pack.yaml ]; do
echo "waiting for packs";
sleep 2;
done
volumeMounts:
- name: packs
mountPath: /opt/attune/packs
containers:
- name: sensor
image: {{ include "attune.image" (dict "root" . "image" .Values.images.sensor) }}
imagePullPolicy: {{ .Values.images.sensor.pullPolicy }}
command: ["/opt/attune/agent/attune-sensor-agent"]
envFrom:
- secretRef:
name: {{ include "attune.secretName" . }}
env:
- name: ATTUNE_CONFIG
value: /opt/attune/config.yaml
- name: ATTUNE__DATABASE__SCHEMA
value: {{ .Values.database.schema | quote }}
- name: ATTUNE__WORKER__WORKER_TYPE
value: container
- name: ATTUNE_SENSOR_RUNTIMES
value: {{ .Values.sensor.runtimes | quote }}
- name: ATTUNE_API_URL
value: http://{{ include "attune.apiServiceName" . }}:{{ .Values.api.service.port }}
- name: ATTUNE_MQ_URL
value: {{ include "attune.rabbitmqUrl" . | quote }}
- name: ATTUNE_PACKS_BASE_DIR
value: /opt/attune/packs
- name: RUST_LOG
value: {{ .Values.sensor.logLevel | quote }}
resources:
{{- toYaml .Values.sensor.resources | nindent 12 }}
volumeMounts:
- name: agent-bin
mountPath: /opt/attune/agent
readOnly: true
- name: config
mountPath: /opt/attune/config.yaml
subPath: config.yaml
- name: packs
mountPath: /opt/attune/packs
readOnly: true
- name: runtime-envs
mountPath: /opt/attune/runtime_envs
volumes:
- name: agent-bin
emptyDir: {}
- name: config
configMap:
name: {{ include "attune.fullname" . }}-config
- name: packs
persistentVolumeClaim:
claimName: {{ include "attune.fullname" . }}-packs
- name: runtime-envs
persistentVolumeClaim:
claimName: {{ include "attune.fullname" . }}-runtime-envs
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "attune.notifierServiceName" . }}
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
type: {{ .Values.notifier.service.type }}
selector:
{{- include "attune.componentLabels" (dict "root" . "component" "notifier") | nindent 4 }}
ports:
- name: ws
port: {{ .Values.notifier.service.port }}
targetPort: ws
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "attune.notifierServiceName" . }}
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.notifier.replicaCount }}
selector:
matchLabels:
{{- include "attune.componentLabels" (dict "root" . "component" "notifier") | nindent 6 }}
template:
metadata:
labels:
{{- include "attune.componentLabels" (dict "root" . "component" "notifier") | nindent 8 }}
spec:
{{- if .Values.global.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.global.imagePullSecrets | nindent 8 }}
{{- end }}
initContainers:
- name: wait-for-schema
image: postgres:16-alpine
command: ["/bin/sh", "-ec"]
args:
- |
until PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -tAc "SELECT to_regclass('${DB_SCHEMA}.identity')" | grep -q identity; do
echo "waiting for schema";
sleep 2;
done
envFrom:
- secretRef:
name: {{ include "attune.secretName" . }}
containers:
- name: notifier
image: {{ include "attune.image" (dict "root" . "image" .Values.images.notifier) }}
imagePullPolicy: {{ .Values.images.notifier.pullPolicy }}
envFrom:
- secretRef:
name: {{ include "attune.secretName" . }}
env:
- name: ATTUNE_CONFIG
value: /opt/attune/config.yaml
- name: ATTUNE__DATABASE__SCHEMA
value: {{ .Values.database.schema | quote }}
- name: ATTUNE__WORKER__WORKER_TYPE
value: container
ports:
- name: ws
containerPort: 8081
readinessProbe:
httpGet:
path: /health
port: ws
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: ws
initialDelaySeconds: 20
periodSeconds: 15
resources:
{{- toYaml .Values.notifier.resources | nindent 12 }}
volumeMounts:
- name: config
mountPath: /opt/attune/config.yaml
subPath: config.yaml
volumes:
- name: config
configMap:
name: {{ include "attune.fullname" . }}-config
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "attune.fullname" . }}-web
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
type: {{ .Values.web.service.type }}
selector:
{{- include "attune.componentLabels" (dict "root" . "component" "web") | nindent 4 }}
ports:
- name: http
port: {{ .Values.web.service.port }}
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "attune.fullname" . }}-web
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.web.replicaCount }}
selector:
matchLabels:
{{- include "attune.componentLabels" (dict "root" . "component" "web") | nindent 6 }}
template:
metadata:
labels:
{{- include "attune.componentLabels" (dict "root" . "component" "web") | nindent 8 }}
spec:
{{- if .Values.global.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.global.imagePullSecrets | nindent 8 }}
{{- end }}
containers:
- name: web
image: {{ include "attune.image" (dict "root" . "image" .Values.images.web) }}
imagePullPolicy: {{ .Values.images.web.pullPolicy }}
env:
- name: API_URL
value: {{ .Values.web.config.apiUrl | quote }}
- name: WS_URL
value: {{ .Values.web.config.wsUrl | quote }}
- name: ENVIRONMENT
value: {{ .Values.web.config.environment | quote }}
ports:
- name: http
containerPort: 80
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 20
periodSeconds: 15
resources:
{{- toYaml .Values.web.resources | nindent 12 }}

View File

@@ -0,0 +1,9 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "attune.fullname" . }}-config
labels:
{{- include "attune.labels" . | nindent 4 }}
data:
config.yaml: |
{{ .Files.Get "files/config.docker.yaml" | indent 4 }}

View File

@@ -0,0 +1,225 @@
{{- if .Values.database.postgresql.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "attune.postgresqlServiceName" . }}
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
selector:
{{- include "attune.componentLabels" (dict "root" . "component" "postgresql") | nindent 4 }}
ports:
- name: postgres
port: {{ .Values.database.port }}
targetPort: postgres
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "attune.postgresqlServiceName" . }}
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
serviceName: {{ include "attune.postgresqlServiceName" . }}
replicas: 1
selector:
matchLabels:
{{- include "attune.componentLabels" (dict "root" . "component" "postgresql") | nindent 6 }}
template:
metadata:
labels:
{{- include "attune.componentLabels" (dict "root" . "component" "postgresql") | nindent 8 }}
spec:
containers:
- name: postgresql
image: "{{ .Values.database.postgresql.image.repository }}:{{ .Values.database.postgresql.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: POSTGRES_USER
value: {{ .Values.database.username | quote }}
- name: POSTGRES_PASSWORD
value: {{ .Values.database.password | quote }}
- name: POSTGRES_DB
value: {{ .Values.database.database | quote }}
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
ports:
- name: postgres
containerPort: 5432
livenessProbe:
exec:
command: ["pg_isready", "-U", "{{ .Values.database.username }}"]
initialDelaySeconds: 20
periodSeconds: 10
readinessProbe:
exec:
command: ["pg_isready", "-U", "{{ .Values.database.username }}"]
initialDelaySeconds: 10
periodSeconds: 10
resources:
{{- toYaml .Values.database.postgresql.resources | nindent 12 }}
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
{{- toYaml .Values.database.postgresql.persistence.accessModes | nindent 10 }}
resources:
requests:
storage: {{ .Values.database.postgresql.persistence.size }}
{{- if .Values.database.postgresql.persistence.storageClassName }}
storageClassName: {{ .Values.database.postgresql.persistence.storageClassName }}
{{- end }}
{{- end }}
{{- if .Values.rabbitmq.enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "attune.rabbitmqServiceName" . }}
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
selector:
{{- include "attune.componentLabels" (dict "root" . "component" "rabbitmq") | nindent 4 }}
ports:
- name: amqp
port: {{ .Values.rabbitmq.port }}
targetPort: amqp
- name: management
port: {{ .Values.rabbitmq.managementPort }}
targetPort: management
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "attune.rabbitmqServiceName" . }}
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
serviceName: {{ include "attune.rabbitmqServiceName" . }}
replicas: 1
selector:
matchLabels:
{{- include "attune.componentLabels" (dict "root" . "component" "rabbitmq") | nindent 6 }}
template:
metadata:
labels:
{{- include "attune.componentLabels" (dict "root" . "component" "rabbitmq") | nindent 8 }}
spec:
containers:
- name: rabbitmq
image: "{{ .Values.rabbitmq.image.repository }}:{{ .Values.rabbitmq.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: RABBITMQ_DEFAULT_USER
value: {{ .Values.rabbitmq.username | quote }}
- name: RABBITMQ_DEFAULT_PASS
value: {{ .Values.rabbitmq.password | quote }}
- name: RABBITMQ_DEFAULT_VHOST
value: /
ports:
- name: amqp
containerPort: 5672
- name: management
containerPort: 15672
livenessProbe:
exec:
command: ["rabbitmq-diagnostics", "-q", "ping"]
initialDelaySeconds: 20
periodSeconds: 15
readinessProbe:
exec:
command: ["rabbitmq-diagnostics", "-q", "ping"]
initialDelaySeconds: 10
periodSeconds: 10
resources:
{{- toYaml .Values.rabbitmq.resources | nindent 12 }}
volumeMounts:
- name: data
mountPath: /var/lib/rabbitmq
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
{{- toYaml .Values.rabbitmq.persistence.accessModes | nindent 10 }}
resources:
requests:
storage: {{ .Values.rabbitmq.persistence.size }}
{{- if .Values.rabbitmq.persistence.storageClassName }}
storageClassName: {{ .Values.rabbitmq.persistence.storageClassName }}
{{- end }}
{{- end }}
{{- if .Values.redis.enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "attune.redisServiceName" . }}
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
selector:
{{- include "attune.componentLabels" (dict "root" . "component" "redis") | nindent 4 }}
ports:
- name: redis
port: {{ .Values.redis.port }}
targetPort: redis
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "attune.redisServiceName" . }}
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
serviceName: {{ include "attune.redisServiceName" . }}
replicas: 1
selector:
matchLabels:
{{- include "attune.componentLabels" (dict "root" . "component" "redis") | nindent 6 }}
template:
metadata:
labels:
{{- include "attune.componentLabels" (dict "root" . "component" "redis") | nindent 8 }}
spec:
containers:
- name: redis
image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}"
imagePullPolicy: IfNotPresent
command: ["redis-server", "--appendonly", "yes"]
ports:
- name: redis
containerPort: 6379
livenessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 10
periodSeconds: 10
resources:
{{- toYaml .Values.redis.resources | nindent 12 }}
volumeMounts:
- name: data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
{{- toYaml .Values.redis.persistence.accessModes | nindent 10 }}
resources:
requests:
storage: {{ .Values.redis.persistence.size }}
{{- if .Values.redis.persistence.storageClassName }}
storageClassName: {{ .Values.redis.persistence.storageClassName }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,35 @@
{{- if .Values.web.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "attune.fullname" . }}-web
labels:
{{- include "attune.labels" . | nindent 4 }}
{{- with .Values.web.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.web.ingress.className }}
ingressClassName: {{ .Values.web.ingress.className }}
{{- end }}
rules:
{{- range .Values.web.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "attune.fullname" $ }}-web
port:
number: {{ $.Values.web.service.port }}
{{- end }}
{{- end }}
{{- with .Values.web.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,154 @@
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "attune.fullname" . }}-migrations
labels:
{{- include "attune.labels" . | nindent 4 }}
app.kubernetes.io/component: migrations
annotations:
helm.sh/hook: post-install,post-upgrade
helm.sh/hook-weight: "-20"
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec:
ttlSecondsAfterFinished: {{ .Values.jobs.migrations.ttlSecondsAfterFinished }}
template:
metadata:
labels:
{{- include "attune.componentLabels" (dict "root" . "component" "migrations") | nindent 8 }}
spec:
restartPolicy: OnFailure
{{- if .Values.global.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.global.imagePullSecrets | nindent 8 }}
{{- end }}
containers:
- name: migrations
image: {{ include "attune.image" (dict "root" . "image" .Values.images.migrations) }}
imagePullPolicy: {{ .Values.images.migrations.pullPolicy }}
envFrom:
- secretRef:
name: {{ include "attune.secretName" . }}
env:
- name: MIGRATIONS_DIR
value: /migrations
resources:
{{- toYaml .Values.jobs.migrations.resources | nindent 12 }}
---
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "attune.fullname" . }}-init-user
labels:
{{- include "attune.labels" . | nindent 4 }}
app.kubernetes.io/component: init-user
annotations:
helm.sh/hook: post-install,post-upgrade
helm.sh/hook-weight: "-10"
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec:
ttlSecondsAfterFinished: {{ .Values.jobs.initUser.ttlSecondsAfterFinished }}
template:
metadata:
labels:
{{- include "attune.componentLabels" (dict "root" . "component" "init-user") | nindent 8 }}
spec:
restartPolicy: OnFailure
{{- if .Values.global.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.global.imagePullSecrets | nindent 8 }}
{{- end }}
containers:
- name: init-user
image: {{ include "attune.image" (dict "root" . "image" .Values.images.initUser) }}
imagePullPolicy: {{ .Values.images.initUser.pullPolicy }}
envFrom:
- secretRef:
name: {{ include "attune.secretName" . }}
command: ["/bin/sh", "-ec"]
args:
- |
until PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -tAc "SELECT to_regclass('${DB_SCHEMA}.identity')" | grep -q identity; do
echo "waiting for database schema";
sleep 2;
done
exec /init-user.sh
resources:
{{- toYaml .Values.jobs.initUser.resources | nindent 12 }}
---
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "attune.fullname" . }}-init-packs
labels:
{{- include "attune.labels" . | nindent 4 }}
app.kubernetes.io/component: init-packs
annotations:
helm.sh/hook: post-install,post-upgrade
helm.sh/hook-weight: "0"
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec:
ttlSecondsAfterFinished: {{ .Values.jobs.initPacks.ttlSecondsAfterFinished }}
template:
metadata:
labels:
{{- include "attune.componentLabels" (dict "root" . "component" "init-packs") | nindent 8 }}
spec:
restartPolicy: OnFailure
{{- if .Values.global.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.global.imagePullSecrets | nindent 8 }}
{{- end }}
containers:
- name: init-packs
image: {{ include "attune.image" (dict "root" . "image" .Values.images.initPacks) }}
imagePullPolicy: {{ .Values.images.initPacks.pullPolicy }}
envFrom:
- secretRef:
name: {{ include "attune.secretName" . }}
command: ["/bin/sh", "-ec"]
args:
- |
until python3 - <<'PY'
import os
import psycopg2
conn = psycopg2.connect(
host=os.environ["DB_HOST"],
port=os.environ["DB_PORT"],
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
dbname=os.environ["DB_NAME"],
)
try:
with conn.cursor() as cur:
cur.execute("SET search_path TO %s, public" % os.environ["DB_SCHEMA"])
cur.execute("SELECT to_regclass(%s)", (f"{os.environ['DB_SCHEMA']}.identity",))
value = cur.fetchone()[0]
raise SystemExit(0 if value else 1)
finally:
conn.close()
PY
do
echo "waiting for database schema";
sleep 2;
done
exec /init-packs.sh
volumeMounts:
- name: packs
mountPath: /opt/attune/packs
- name: runtime-envs
mountPath: /opt/attune/runtime_envs
- name: artifacts
mountPath: /opt/attune/artifacts
resources:
{{- toYaml .Values.jobs.initPacks.resources | nindent 12 }}
volumes:
- name: packs
persistentVolumeClaim:
claimName: {{ include "attune.fullname" . }}-packs
- name: runtime-envs
persistentVolumeClaim:
claimName: {{ include "attune.fullname" . }}-runtime-envs
- name: artifacts
persistentVolumeClaim:
claimName: {{ include "attune.fullname" . }}-artifacts

View File

@@ -0,0 +1,53 @@
{{- if .Values.sharedStorage.packs.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "attune.fullname" . }}-packs
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
accessModes:
{{- toYaml .Values.sharedStorage.packs.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.sharedStorage.packs.size }}
{{- if .Values.sharedStorage.packs.storageClassName }}
storageClassName: {{ .Values.sharedStorage.packs.storageClassName }}
{{- end }}
---
{{- end }}
{{- if .Values.sharedStorage.runtimeEnvs.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "attune.fullname" . }}-runtime-envs
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
accessModes:
{{- toYaml .Values.sharedStorage.runtimeEnvs.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.sharedStorage.runtimeEnvs.size }}
{{- if .Values.sharedStorage.runtimeEnvs.storageClassName }}
storageClassName: {{ .Values.sharedStorage.runtimeEnvs.storageClassName }}
{{- end }}
---
{{- end }}
{{- if .Values.sharedStorage.artifacts.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "attune.fullname" . }}-artifacts
labels:
{{- include "attune.labels" . | nindent 4 }}
spec:
accessModes:
{{- toYaml .Values.sharedStorage.artifacts.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.sharedStorage.artifacts.size }}
{{- if .Values.sharedStorage.artifacts.storageClassName }}
storageClassName: {{ .Values.sharedStorage.artifacts.storageClassName }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,31 @@
{{- if not .Values.security.existingSecret }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "attune.secretName" . }}
labels:
{{- include "attune.labels" . | nindent 4 }}
type: Opaque
stringData:
ATTUNE__SECURITY__JWT_SECRET: {{ .Values.security.jwtSecret | quote }}
ATTUNE__SECURITY__ENCRYPTION_KEY: {{ .Values.security.encryptionKey | quote }}
ATTUNE__DATABASE__URL: {{ include "attune.databaseUrl" . | quote }}
ATTUNE__MESSAGE_QUEUE__URL: {{ include "attune.rabbitmqUrl" . | quote }}
ATTUNE__CACHE__URL: {{ include "attune.redisUrl" . | quote }}
DB_HOST: {{ include "attune.postgresqlServiceName" . | quote }}
DB_PORT: {{ .Values.database.port | quote }}
DB_USER: {{ .Values.database.username | quote }}
DB_PASSWORD: {{ .Values.database.password | quote }}
DB_NAME: {{ .Values.database.database | quote }}
DB_SCHEMA: {{ .Values.database.schema | quote }}
TEST_LOGIN: {{ .Values.bootstrap.testUser.login | quote }}
TEST_DISPLAY_NAME: {{ .Values.bootstrap.testUser.displayName | quote }}
TEST_PASSWORD: {{ .Values.bootstrap.testUser.password | quote }}
DEFAULT_ADMIN_LOGIN: {{ .Values.bootstrap.testUser.login | quote }}
DEFAULT_ADMIN_PERMISSION_SET_REF: "core.admin"
SOURCE_PACKS_DIR: "/source/packs"
TARGET_PACKS_DIR: "/opt/attune/packs"
RUNTIME_ENVS_DIR: "/opt/attune/runtime_envs"
ARTIFACTS_DIR: "/opt/attune/artifacts"
LOADER_SCRIPT: "/scripts/load_core_pack.py"
{{- end }}

253
charts/attune/values.yaml Normal file
View File

@@ -0,0 +1,253 @@
nameOverride: ""
fullnameOverride: ""
global:
imageRegistry: ""
imageNamespace: ""
imageTag: edge
imagePullSecrets: []
security:
existingSecret: ""
jwtSecret: change-me-in-production
encryptionKey: change-me-in-production-32-bytes-minimum
database:
schema: public
username: attune
password: attune
database: attune
host: ""
port: 5432
url: ""
postgresql:
enabled: true
image:
repository: timescale/timescaledb
tag: 2.17.2-pg16
persistence:
enabled: true
accessModes:
- ReadWriteOnce
size: 20Gi
storageClassName: ""
resources: {}
rabbitmq:
username: attune
password: attune
host: ""
port: 5672
url: ""
managementPort: 15672
enabled: true
image:
repository: rabbitmq
tag: 3.13-management-alpine
persistence:
enabled: true
accessModes:
- ReadWriteOnce
size: 8Gi
storageClassName: ""
resources: {}
redis:
enabled: true
host: ""
port: 6379
url: ""
image:
repository: redis
tag: 7-alpine
persistence:
enabled: true
accessModes:
- ReadWriteOnce
size: 8Gi
storageClassName: ""
resources: {}
bootstrap:
testUser:
login: test@attune.local
displayName: Test User
password: TestPass123!
sharedStorage:
packs:
enabled: true
accessModes:
- ReadWriteMany
size: 2Gi
storageClassName: ""
runtimeEnvs:
enabled: true
accessModes:
- ReadWriteMany
size: 10Gi
storageClassName: ""
artifacts:
enabled: true
accessModes:
- ReadWriteMany
size: 20Gi
storageClassName: ""
images:
api:
repository: attune-api
tag: ""
pullPolicy: IfNotPresent
executor:
repository: attune-executor
tag: ""
pullPolicy: IfNotPresent
worker:
repository: attune-worker
tag: ""
pullPolicy: IfNotPresent
sensor:
repository: nikolaik/python-nodejs
tag: python3.12-nodejs22-slim
pullPolicy: IfNotPresent
notifier:
repository: attune-notifier
tag: ""
pullPolicy: IfNotPresent
web:
repository: attune-web
tag: ""
pullPolicy: IfNotPresent
migrations:
repository: attune-migrations
tag: ""
pullPolicy: IfNotPresent
initUser:
repository: attune-init-user
tag: ""
pullPolicy: IfNotPresent
initPacks:
repository: attune-init-packs
tag: ""
pullPolicy: IfNotPresent
agent:
repository: attune-agent
tag: ""
pullPolicy: IfNotPresent
jobs:
migrations:
ttlSecondsAfterFinished: 300
resources: {}
initUser:
ttlSecondsAfterFinished: 300
resources: {}
initPacks:
ttlSecondsAfterFinished: 300
resources: {}
api:
replicaCount: 1
service:
type: ClusterIP
port: 8080
resources: {}
executor:
replicaCount: 1
resources: {}
worker:
replicaCount: 1
runtimes: shell,python,node,native
name: worker-full-01
resources: {}
sensor:
replicaCount: 1
runtimes: shell,python,node,native
logLevel: debug
resources: {}
notifier:
replicaCount: 1
service:
type: ClusterIP
port: 8081
resources: {}
web:
replicaCount: 1
service:
type: ClusterIP
port: 80
config:
environment: kubernetes
apiUrl: http://localhost:8080
wsUrl: ws://localhost:8081
resources: {}
ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: attune.local
paths:
- path: /
pathType: Prefix
tls: []
# Agent-based workers
# These deploy the universal worker agent into any container image.
# The agent auto-detects available runtimes (python, ruby, node, etc.)
# and registers with the Attune platform.
#
# Each entry creates a separate Deployment with an init container that
# copies the statically-linked agent binary into the worker container.
#
# Supported fields per worker:
# name (required) - Unique name for this worker (used in resource names)
# image (required) - Container image with your desired runtime(s)
# replicas (optional) - Number of pod replicas (default: 1)
# runtimes (optional) - List of runtimes to expose; [] = auto-detect
# resources (optional) - Kubernetes resource requests/limits
# env (optional) - Extra environment variables (list of {name, value})
# imagePullPolicy (optional) - Pull policy for the worker image
# logLevel (optional) - RUST_LOG level (default: "info")
# runtimeClassName (optional) - Kubernetes RuntimeClass (e.g., "nvidia" for GPU)
# nodeSelector (optional) - Node selector map for pod scheduling
# tolerations (optional) - Tolerations list for pod scheduling
# stopGracePeriod (optional) - Termination grace period in seconds (default: 45)
#
# Examples:
# agentWorkers:
# - name: ruby
# image: ruby:3.3
# replicas: 2
# runtimes: [] # auto-detect
# resources: {}
#
# - name: python-gpu
# image: nvidia/cuda:12.3.1-runtime-ubuntu22.04
# replicas: 1
# runtimes: [python, shell]
# runtimeClassName: nvidia
# nodeSelector:
# gpu: "true"
# tolerations:
# - key: nvidia.com/gpu
# operator: Exists
# effect: NoSchedule
# resources:
# limits:
# nvidia.com/gpu: 1
#
# - name: custom
# image: my-org/my-custom-image:latest
# replicas: 1
# runtimes: []
# env:
# - name: MY_CUSTOM_VAR
# value: my-value
agentWorkers: []

View File

@@ -46,6 +46,22 @@ security:
jwt_refresh_expiration: 2592000 # 30 days jwt_refresh_expiration: 2592000 # 30 days
encryption_key: test-encryption-key-32-chars-okay encryption_key: test-encryption-key-32-chars-okay
enable_auth: true enable_auth: true
allow_self_registration: true
oidc:
enabled: false
discovery_url: https://auth.rdrx.app/.well-known/openid-configuration
client_id: 31d194737840d32bd3afe6474826976bae346d77247a158c4dc43887278eb605
client_secret: null
redirect_uri: http://localhost:3000/auth/callback
post_logout_redirect_uri: http://localhost:3000/login
scopes:
- groups
ldap:
enabled: false
url: ldap://localhost:389
bind_dn_template: "uid={login},ou=users,dc=example,dc=com"
provider_name: ldap
provider_label: Development LDAP
# Packs directory (where pack action files are located) # Packs directory (where pack action files are located)
packs_base_dir: ./packs packs_base_dir: ./packs
@@ -109,3 +125,8 @@ executor:
scheduled_timeout: 120 # 2 minutes (faster feedback in dev) scheduled_timeout: 120 # 2 minutes (faster feedback in dev)
timeout_check_interval: 30 # Check every 30 seconds timeout_check_interval: 30 # Check every 30 seconds
enable_timeout_monitor: true enable_timeout_monitor: true
# Agent binary distribution (optional - for local development)
# Binary is built via: make build-agent
# agent:
# binary_dir: ./target/x86_64-unknown-linux-musl/release

View File

@@ -86,6 +86,48 @@ security:
# Enable authentication # Enable authentication
enable_auth: true enable_auth: true
# Login page defaults for the web UI. Users can still override with:
# /login?auth=direct
# /login?auth=<provider_name>
login_page:
show_local_login: true
show_oidc_login: true
show_ldap_login: true
# Optional OIDC browser login configuration
oidc:
enabled: false
discovery_url: https://auth.example.com/.well-known/openid-configuration
client_id: your-confidential-client-id
provider_name: sso
provider_label: Example SSO
provider_icon_url: https://auth.example.com/assets/logo.svg
client_secret: your-confidential-client-secret
redirect_uri: http://localhost:3000/auth/callback
post_logout_redirect_uri: http://localhost:3000/login
scopes:
- groups
# Optional LDAP authentication configuration
ldap:
enabled: false
url: ldap://ldap.example.com:389
# Direct-bind mode: construct DN from template
# bind_dn_template: "uid={login},ou=users,dc=example,dc=com"
# Search-and-bind mode: search for user with a service account
user_search_base: "ou=users,dc=example,dc=com"
user_filter: "(uid={login})"
search_bind_dn: "cn=readonly,dc=example,dc=com"
search_bind_password: "readonly-password"
login_attr: uid
email_attr: mail
display_name_attr: cn
group_attr: memberOf
starttls: false
danger_skip_tls_verify: false
provider_name: ldap
provider_label: Company LDAP
# Worker configuration (optional, for worker services) # Worker configuration (optional, for worker services)
# Uncomment and configure if running worker processes # Uncomment and configure if running worker processes
# worker: # worker:

View File

@@ -48,6 +48,7 @@ security:
jwt_refresh_expiration: 3600 # 1 hour jwt_refresh_expiration: 3600 # 1 hour
encryption_key: test-encryption-key-32-chars-okay encryption_key: test-encryption-key-32-chars-okay
enable_auth: true enable_auth: true
allow_self_registration: true
# Test packs directory (use /tmp for tests to avoid permission issues) # Test packs directory (use /tmp for tests to avoid permission issues)
packs_base_dir: /tmp/attune-test-packs packs_base_dir: /tmp/attune-test-packs

View File

@@ -27,6 +27,8 @@ futures = { workspace = true }
# Web framework # Web framework
axum = { workspace = true, features = ["multipart"] } axum = { workspace = true, features = ["multipart"] }
axum-extra = { version = "0.10", features = ["cookie"] }
cookie = "0.18"
tower = { workspace = true } tower = { workspace = true }
tower-http = { workspace = true } tower-http = { workspace = true }
@@ -67,6 +69,9 @@ jsonschema = { workspace = true }
# HTTP client # HTTP client
reqwest = { workspace = true } reqwest = { workspace = true }
openidconnect = "4.0"
ldap3 = "0.12"
url = { workspace = true }
# Archive/compression # Archive/compression
tar = { workspace = true } tar = { workspace = true }
@@ -77,17 +82,19 @@ tempfile = { workspace = true }
# Authentication # Authentication
argon2 = { workspace = true } argon2 = { workspace = true }
rand = "0.9" rand = "0.10"
# HMAC and cryptography # HMAC and cryptography
hmac = "0.12" hmac = "0.12"
sha1 = "0.10" sha1 = "0.10"
sha2 = { workspace = true } sha2 = { workspace = true }
hex = "0.4" hex = "0.4"
subtle = "2.6"
# OpenAPI/Swagger # OpenAPI/Swagger
utoipa = { workspace = true, features = ["axum_extras"] } utoipa = { workspace = true, features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0", features = ["axum"] } utoipa-swagger-ui = { version = "9.0", features = ["axum"] }
jsonwebtoken = { workspace = true, features = ["rust_crypto"] }
[dev-dependencies] [dev-dependencies]
mockall = { workspace = true } mockall = { workspace = true }

503
crates/api/src/auth/ldap.rs Normal file
View File

@@ -0,0 +1,503 @@
//! LDAP authentication helpers for username/password login.
use attune_common::{
config::LdapConfig,
repositories::{
identity::{
CreateIdentityInput, IdentityRepository, IdentityRoleAssignmentRepository,
UpdateIdentityInput,
},
Create, Update,
},
};
use ldap3::{dn_escape, ldap_escape, Ldap, LdapConnAsync, LdapConnSettings, Scope, SearchEntry};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::{Digest, Sha256};
use crate::{
auth::jwt::{generate_access_token, generate_refresh_token},
dto::TokenResponse,
middleware::error::ApiError,
state::SharedState,
};
/// Claims extracted from the LDAP directory for an authenticated user.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LdapUserClaims {
/// The LDAP server URL the user was authenticated against.
pub server_url: String,
/// The user's full distinguished name.
pub dn: String,
/// Login attribute value (uid, sAMAccountName, etc.).
pub login: Option<String>,
/// Email address.
pub email: Option<String>,
/// Display name (cn).
pub display_name: Option<String>,
/// Group memberships (memberOf values).
pub groups: Vec<String>,
}
/// The result of a successful LDAP authentication.
#[derive(Debug, Clone)]
pub struct LdapAuthenticatedIdentity {
pub token_response: TokenResponse,
}
/// Authenticate a user against the configured LDAP directory.
///
/// This performs a bind (either direct or search+bind) to verify
/// the user's credentials, then fetches their attributes and upserts
/// the identity in the database.
pub async fn authenticate(
state: &SharedState,
login: &str,
password: &str,
) -> Result<LdapAuthenticatedIdentity, ApiError> {
let ldap_config = ldap_config(state)?;
// Connect and authenticate
let claims = if ldap_config.bind_dn_template.is_some() {
direct_bind(&ldap_config, login, password).await?
} else {
search_and_bind(&ldap_config, login, password).await?
};
// Upsert identity in DB and issue JWT tokens
let identity = upsert_identity(state, &claims).await?;
if identity.frozen {
return Err(ApiError::Forbidden(
"Identity is frozen and cannot authenticate".to_string(),
));
}
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
let token_response = TokenResponse::new(
access_token,
refresh_token,
state.jwt_config.access_token_expiration,
)
.with_user(
identity.id,
identity.login.clone(),
identity.display_name.clone(),
);
Ok(LdapAuthenticatedIdentity { token_response })
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
fn ldap_config(state: &SharedState) -> Result<LdapConfig, ApiError> {
let config = state
.config
.security
.ldap
.clone()
.filter(|ldap| ldap.enabled)
.ok_or_else(|| {
ApiError::NotImplemented("LDAP authentication is not configured".to_string())
})?;
// Reject partial service-account configuration: having exactly one of
// search_bind_dn / search_bind_password is almost certainly a config
// error and would silently fall back to anonymous search, which is a
// very different security posture than the admin intended.
let has_dn = config.search_bind_dn.is_some();
let has_pw = config.search_bind_password.is_some();
if has_dn != has_pw {
let missing = if has_dn {
"search_bind_password"
} else {
"search_bind_dn"
};
return Err(ApiError::InternalServerError(format!(
"LDAP misconfiguration: search_bind_dn and search_bind_password must both be set \
or both be omitted (missing {missing})"
)));
}
Ok(config)
}
/// Build an `LdapConnSettings` from the config.
fn conn_settings(config: &LdapConfig) -> LdapConnSettings {
let mut settings = LdapConnSettings::new();
if config.starttls {
settings = settings.set_starttls(true);
}
if config.danger_skip_tls_verify {
settings = settings.set_no_tls_verify(true);
}
settings
}
/// Open a new LDAP connection.
async fn connect(config: &LdapConfig) -> Result<Ldap, ApiError> {
let settings = conn_settings(config);
let (conn, ldap) = LdapConnAsync::with_settings(settings, &config.url)
.await
.map_err(|err| {
ApiError::InternalServerError(format!("Failed to connect to LDAP server: {err}"))
})?;
// Drive the connection in the background
ldap3::drive!(conn);
Ok(ldap)
}
/// Direct-bind authentication: construct the DN from the template and bind.
async fn direct_bind(
config: &LdapConfig,
login: &str,
password: &str,
) -> Result<LdapUserClaims, ApiError> {
let template = config.bind_dn_template.as_deref().unwrap_or_default();
// Escape the login value for safe interpolation into a Distinguished Name
// (RFC 4514). Without this, characters like `,`, `+`, `"`, `\`, `<`, `>`,
// `;`, `=`, NUL, `#` (leading), or space (leading/trailing) in the username
// would alter the DN structure.
let escaped_login = dn_escape(login);
let bind_dn = template.replace("{login}", &escaped_login);
let mut ldap = connect(config).await?;
// Bind as the user
let result = ldap
.simple_bind(&bind_dn, password)
.await
.map_err(|err| ApiError::InternalServerError(format!("LDAP bind failed: {err}")))?;
if result.rc != 0 {
let _ = ldap.unbind().await;
return Err(ApiError::Unauthorized(
"Invalid LDAP credentials".to_string(),
));
}
// Fetch user attributes
let claims = fetch_user_attributes(config, &mut ldap, &bind_dn).await?;
let _ = ldap.unbind().await;
Ok(claims)
}
/// Search-and-bind authentication:
/// 1. Bind as the service account (or anonymous)
/// 2. Search for the user entry (must match exactly one)
/// 3. Re-bind as the user with their DN + password
async fn search_and_bind(
config: &LdapConfig,
login: &str,
password: &str,
) -> Result<LdapUserClaims, ApiError> {
let search_base = config.user_search_base.as_deref().ok_or_else(|| {
ApiError::InternalServerError(
"LDAP user_search_base is required when bind_dn_template is not set".to_string(),
)
})?;
let mut ldap = connect(config).await?;
// Step 1: Bind as service account or anonymous.
// Partial config (only one of dn/password) is already rejected by
// ldap_config(), so this match is exhaustive over valid states.
if let (Some(bind_dn), Some(bind_pw)) = (
config.search_bind_dn.as_deref(),
config.search_bind_password.as_deref(),
) {
let result = ldap.simple_bind(bind_dn, bind_pw).await.map_err(|err| {
ApiError::InternalServerError(format!("LDAP service bind failed: {err}"))
})?;
if result.rc != 0 {
let _ = ldap.unbind().await;
return Err(ApiError::InternalServerError(
"LDAP service account bind failed — check search_bind_dn and search_bind_password"
.to_string(),
));
}
}
// If no service account, we proceed with an anonymous connection (already connected)
// Step 2: Search for the user.
// Escape the login value for safe interpolation into an LDAP search filter
// (RFC 4515). Without this, characters like `(`, `)`, `*`, `\`, and NUL in
// the username could broaden the filter, match unintended entries, or break
// the search entirely.
let escaped_login = ldap_escape(login);
let filter = config.user_filter.replace("{login}", &escaped_login);
let attrs = vec![
config.login_attr.as_str(),
config.email_attr.as_str(),
config.display_name_attr.as_str(),
config.group_attr.as_str(),
"dn",
];
let (results, _result) = ldap
.search(search_base, Scope::Subtree, &filter, attrs)
.await
.map_err(|err| ApiError::InternalServerError(format!("LDAP user search failed: {err}")))?
.success()
.map_err(|err| ApiError::InternalServerError(format!("LDAP search error: {err}")))?;
// The search must return exactly one entry. Zero means the user was not
// found; more than one means the filter or directory layout is ambiguous
// and we must not guess which identity to authenticate.
let result_count = results.len();
if result_count == 0 {
let _ = ldap.unbind().await;
return Err(ApiError::Unauthorized(
"Invalid LDAP credentials".to_string(),
));
}
if result_count > 1 {
let _ = ldap.unbind().await;
return Err(ApiError::InternalServerError(format!(
"LDAP user search returned {result_count} entries (expected exactly 1) — \
tighten the user_filter or user_search_base to ensure uniqueness"
)));
}
// SAFETY: result_count == 1 guaranteed by the checks above.
let entry = results
.into_iter()
.next()
.expect("checked result_count == 1");
let search_entry = SearchEntry::construct(entry);
let user_dn = search_entry.dn.clone();
// Step 3: Re-bind as the user
let result = ldap
.simple_bind(&user_dn, password)
.await
.map_err(|err| ApiError::InternalServerError(format!("LDAP user bind failed: {err}")))?;
if result.rc != 0 {
let _ = ldap.unbind().await;
return Err(ApiError::Unauthorized(
"Invalid LDAP credentials".to_string(),
));
}
let claims = extract_claims(config, &search_entry);
let _ = ldap.unbind().await;
Ok(claims)
}
/// Fetch the user's LDAP attributes after a successful bind.
async fn fetch_user_attributes(
config: &LdapConfig,
ldap: &mut Ldap,
user_dn: &str,
) -> Result<LdapUserClaims, ApiError> {
let attrs = vec![
config.login_attr.as_str(),
config.email_attr.as_str(),
config.display_name_attr.as_str(),
config.group_attr.as_str(),
];
let (results, _result) = ldap
.search(user_dn, Scope::Base, "(objectClass=*)", attrs)
.await
.map_err(|err| {
ApiError::InternalServerError(format!(
"LDAP attribute fetch failed for DN {user_dn}: {err}"
))
})?
.success()
.map_err(|err| {
ApiError::InternalServerError(format!("LDAP attribute search error: {err}"))
})?;
let entry = results.into_iter().next().ok_or_else(|| {
ApiError::InternalServerError(format!("LDAP entry not found for DN: {user_dn}"))
})?;
let search_entry = SearchEntry::construct(entry);
Ok(extract_claims(config, &search_entry))
}
/// Extract user claims from an LDAP search entry.
fn extract_claims(config: &LdapConfig, entry: &SearchEntry) -> LdapUserClaims {
let first_attr =
|name: &str| -> Option<String> { entry.attrs.get(name).and_then(|v| v.first()).cloned() };
let groups = entry
.attrs
.get(&config.group_attr)
.cloned()
.unwrap_or_default();
LdapUserClaims {
server_url: config.url.clone(),
dn: entry.dn.clone(),
login: first_attr(&config.login_attr),
email: first_attr(&config.email_attr),
display_name: first_attr(&config.display_name_attr),
groups,
}
}
/// Upsert an identity row for the LDAP-authenticated user.
async fn upsert_identity(
state: &SharedState,
claims: &LdapUserClaims,
) -> Result<attune_common::models::identity::Identity, ApiError> {
let existing =
IdentityRepository::find_by_ldap_dn(&state.db, &claims.server_url, &claims.dn).await?;
let desired_login = derive_login(claims);
let display_name = claims.display_name.clone();
let attributes = json!({ "ldap": claims });
match existing {
Some(identity) => {
let updated = UpdateIdentityInput {
display_name,
password_hash: None,
attributes: Some(attributes),
frozen: None,
};
let identity = IdentityRepository::update(&state.db, identity.id, updated)
.await
.map_err(ApiError::from)?;
sync_roles(&state.db, identity.id, "ldap", &claims.groups).await?;
Ok(identity)
}
None => {
// Avoid login collisions
let login = match IdentityRepository::find_by_login(&state.db, &desired_login).await? {
Some(_) => fallback_dn_login(claims),
None => desired_login,
};
let identity = IdentityRepository::create(
&state.db,
CreateIdentityInput {
login,
display_name,
password_hash: None,
attributes,
},
)
.await
.map_err(ApiError::from)?;
sync_roles(&state.db, identity.id, "ldap", &claims.groups).await?;
Ok(identity)
}
}
}
async fn sync_roles(
db: &sqlx::PgPool,
identity_id: i64,
source: &str,
roles: &[String],
) -> Result<(), ApiError> {
IdentityRoleAssignmentRepository::replace_managed_roles(db, identity_id, source, roles)
.await
.map_err(Into::into)
}
/// Derive the login name from LDAP claims.
fn derive_login(claims: &LdapUserClaims) -> String {
claims
.login
.clone()
.or_else(|| claims.email.clone())
.unwrap_or_else(|| fallback_dn_login(claims))
}
/// Generate a deterministic fallback login from the LDAP server URL + DN.
fn fallback_dn_login(claims: &LdapUserClaims) -> String {
let mut hasher = Sha256::new();
hasher.update(claims.server_url.as_bytes());
hasher.update(b":");
hasher.update(claims.dn.as_bytes());
let digest = hex::encode(hasher.finalize());
format!("ldap:{}", &digest[..24])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn direct_bind_dn_escapes_special_characters() {
// Simulate what direct_bind does with the template
let template = "uid={login},ou=users,dc=example,dc=com";
let malicious_login = "admin,ou=admins,dc=evil,dc=com";
let escaped = dn_escape(malicious_login);
let bind_dn = template.replace("{login}", &escaped);
// The commas in the login value must be escaped so they don't
// introduce additional RDN components.
assert!(
bind_dn.contains("\\2c"),
"commas in login must be escaped in DN: {bind_dn}"
);
assert!(
bind_dn.starts_with("uid=admin\\2cou\\3dadmins\\2cdc\\3devil\\2cdc\\3dcom,ou=users"),
"DN structure must be preserved: {bind_dn}"
);
}
#[test]
fn search_filter_escapes_special_characters() {
let filter_template = "(uid={login})";
let malicious_login = "admin)(|(uid=*))";
let escaped = ldap_escape(malicious_login);
let filter = filter_template.replace("{login}", &escaped);
// The parentheses and asterisk must be escaped so they don't
// alter the filter structure.
assert!(
!filter.contains(")("),
"parentheses in login must be escaped in filter: {filter}"
);
assert!(
filter.contains("\\28"),
"open-paren must be hex-escaped: {filter}"
);
assert!(
filter.contains("\\29"),
"close-paren must be hex-escaped: {filter}"
);
assert!(
filter.contains("\\2a"),
"asterisk must be hex-escaped: {filter}"
);
}
#[test]
fn dn_escape_preserves_safe_usernames() {
let safe = "jdoe";
let escaped = dn_escape(safe);
assert_eq!(escaped.as_ref(), "jdoe");
}
#[test]
fn filter_escape_preserves_safe_usernames() {
let safe = "jdoe";
let escaped = ldap_escape(safe);
assert_eq!(escaped.as_ref(), "jdoe");
}
#[test]
fn fallback_dn_login_is_deterministic() {
let claims = LdapUserClaims {
server_url: "ldap://ldap.example.com".to_string(),
dn: "uid=test,ou=users,dc=example,dc=com".to_string(),
login: None,
email: None,
display_name: None,
groups: vec![],
};
let a = fallback_dn_login(&claims);
let b = fallback_dn_login(&claims);
assert_eq!(a, b);
assert!(a.starts_with("ldap:"));
assert_eq!(a.len(), "ldap:".len() + 24);
}
}

View File

@@ -2,7 +2,7 @@
use axum::{ use axum::{
extract::{Request, State}, extract::{Request, State},
http::{header::AUTHORIZATION, StatusCode}, http::{header::AUTHORIZATION, HeaderMap, StatusCode},
middleware::Next, middleware::Next,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json, Json,
@@ -14,6 +14,8 @@ use attune_common::auth::jwt::{
extract_token_from_header, validate_token, Claims, JwtConfig, TokenType, extract_token_from_header, validate_token, Claims, JwtConfig, TokenType,
}; };
use super::oidc::{cookie_authenticated_user, ACCESS_COOKIE_NAME};
/// Authentication middleware state /// Authentication middleware state
#[derive(Clone)] #[derive(Clone)]
pub struct AuthMiddleware { pub struct AuthMiddleware {
@@ -50,21 +52,7 @@ pub async fn require_auth(
mut request: Request, mut request: Request,
next: Next, next: Next,
) -> Result<Response, AuthError> { ) -> Result<Response, AuthError> {
// Extract Authorization header let claims = extract_claims(request.headers(), &auth.jwt_config)?;
let auth_header = request
.headers()
.get(AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.ok_or(AuthError::MissingToken)?;
// Extract token from Bearer scheme
let token = extract_token_from_header(auth_header).ok_or(AuthError::InvalidToken)?;
// Validate token
let claims = validate_token(token, &auth.jwt_config).map_err(|e| match e {
super::jwt::JwtError::Expired => AuthError::ExpiredToken,
_ => AuthError::InvalidToken,
})?;
// Add claims to request extensions // Add claims to request extensions
request request
@@ -90,22 +78,13 @@ impl axum::extract::FromRequestParts<crate::state::SharedState> for RequireAuth
return Ok(RequireAuth(user.clone())); return Ok(RequireAuth(user.clone()));
} }
// Otherwise, extract and validate token directly from header let claims = if let Some(user) =
// Extract Authorization header cookie_authenticated_user(&parts.headers, state).map_err(map_cookie_auth_error)?
let auth_header = parts {
.headers user.claims
.get(AUTHORIZATION) } else {
.and_then(|h| h.to_str().ok()) extract_claims(&parts.headers, &state.jwt_config)?
.ok_or(AuthError::MissingToken)?; };
// Extract token from Bearer scheme
let token = extract_token_from_header(auth_header).ok_or(AuthError::InvalidToken)?;
// Validate token using jwt_config from app state
let claims = validate_token(token, &state.jwt_config).map_err(|e| match e {
super::jwt::JwtError::Expired => AuthError::ExpiredToken,
_ => AuthError::InvalidToken,
})?;
// Allow access, sensor, and execution-scoped tokens // Allow access, sensor, and execution-scoped tokens
if claims.token_type != TokenType::Access if claims.token_type != TokenType::Access
@@ -119,6 +98,33 @@ impl axum::extract::FromRequestParts<crate::state::SharedState> for RequireAuth
} }
} }
fn extract_claims(headers: &HeaderMap, jwt_config: &JwtConfig) -> Result<Claims, AuthError> {
if let Some(auth_header) = headers.get(AUTHORIZATION).and_then(|h| h.to_str().ok()) {
let token = extract_token_from_header(auth_header).ok_or(AuthError::InvalidToken)?;
return validate_token(token, jwt_config).map_err(|e| match e {
super::jwt::JwtError::Expired => AuthError::ExpiredToken,
_ => AuthError::InvalidToken,
});
}
if headers
.get(axum::http::header::COOKIE)
.and_then(|value| value.to_str().ok())
.is_some_and(|cookies| cookies.contains(ACCESS_COOKIE_NAME))
{
return Err(AuthError::InvalidToken);
}
Err(AuthError::MissingToken)
}
fn map_cookie_auth_error(error: crate::middleware::error::ApiError) -> AuthError {
match error {
crate::middleware::error::ApiError::Unauthorized(_) => AuthError::InvalidToken,
_ => AuthError::InvalidToken,
}
}
/// Authentication errors /// Authentication errors
#[derive(Debug)] #[derive(Debug)]
pub enum AuthError { pub enum AuthError {

View File

@@ -1,7 +1,9 @@
//! Authentication and authorization module //! Authentication and authorization module
pub mod jwt; pub mod jwt;
pub mod ldap;
pub mod middleware; pub mod middleware;
pub mod oidc;
pub mod password; pub mod password;
pub use jwt::{generate_token, validate_token, Claims}; pub use jwt::{generate_token, validate_token, Claims};

797
crates/api/src/auth/oidc.rs Normal file
View File

@@ -0,0 +1,797 @@
//! OpenID Connect helpers for browser login.
use attune_common::{
config::OidcConfig,
repositories::{
identity::{
CreateIdentityInput, IdentityRepository, IdentityRoleAssignmentRepository,
UpdateIdentityInput,
},
Create, Update,
},
};
use axum::{
http::{header, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Redirect, Response},
};
use axum_extra::extract::cookie::{Cookie, SameSite};
use cookie::time::Duration as CookieDuration;
use jsonwebtoken::{
decode, decode_header,
jwk::{AlgorithmParameters, JwkSet},
Algorithm, DecodingKey, Validation,
};
use openidconnect::{
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims},
reqwest::Client as OidcHttpClient,
AuthorizationCode, ClientId, ClientSecret, CsrfToken, LocalizedClaim, Nonce,
OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
TokenResponse as OidcTokenResponse,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value as JsonValue};
use sha2::{Digest, Sha256};
use url::{form_urlencoded::byte_serialize, Url};
use crate::{
auth::jwt::{generate_access_token, generate_refresh_token, validate_token},
dto::{CurrentUserResponse, TokenResponse},
middleware::error::ApiError,
state::SharedState,
};
pub const ACCESS_COOKIE_NAME: &str = "attune_access_token";
pub const REFRESH_COOKIE_NAME: &str = "attune_refresh_token";
pub const OIDC_ID_TOKEN_COOKIE_NAME: &str = "attune_oidc_id_token";
pub const OIDC_STATE_COOKIE_NAME: &str = "attune_oidc_state";
pub const OIDC_NONCE_COOKIE_NAME: &str = "attune_oidc_nonce";
pub const OIDC_PKCE_COOKIE_NAME: &str = "attune_oidc_pkce_verifier";
pub const OIDC_REDIRECT_COOKIE_NAME: &str = "attune_oidc_redirect_to";
const LOGIN_CALLBACK_PATH: &str = "/login/callback";
#[derive(Debug, Clone, Deserialize)]
pub struct OidcDiscoveryDocument {
#[serde(flatten)]
pub metadata: CoreProviderMetadata,
#[serde(default)]
pub end_session_endpoint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcIdentityClaims {
pub issuer: String,
pub sub: String,
pub email: Option<String>,
pub email_verified: Option<bool>,
pub name: Option<String>,
pub preferred_username: Option<String>,
pub groups: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct VerifiedIdTokenClaims {
iss: String,
sub: String,
#[serde(default)]
nonce: Option<String>,
#[serde(default)]
email: Option<String>,
#[serde(default)]
email_verified: Option<bool>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
preferred_username: Option<String>,
#[serde(default)]
groups: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct OidcAuthenticatedIdentity {
pub current_user: CurrentUserResponse,
pub token_response: TokenResponse,
pub id_token: String,
}
#[derive(Debug, Clone)]
pub struct OidcLoginRedirect {
pub authorization_url: String,
pub cookies: Vec<Cookie<'static>>,
}
#[derive(Debug, Clone)]
pub struct OidcLogoutRedirect {
pub redirect_url: String,
pub cookies: Vec<Cookie<'static>>,
}
#[derive(Debug, Deserialize)]
pub struct OidcCallbackQuery {
pub code: Option<String>,
pub state: Option<String>,
pub error: Option<String>,
pub error_description: Option<String>,
}
pub async fn build_login_redirect(
state: &SharedState,
redirect_to: Option<&str>,
) -> Result<OidcLoginRedirect, ApiError> {
let oidc = oidc_config(state)?;
let discovery = fetch_discovery_document(&oidc).await?;
let _http_client = OidcHttpClient::builder()
.redirect(openidconnect::reqwest::redirect::Policy::none())
.build()
.map_err(|err| {
ApiError::InternalServerError(format!("Failed to build OIDC HTTP client: {err}"))
})?;
let redirect_uri = RedirectUrl::new(oidc.redirect_uri.clone()).map_err(|err| {
ApiError::InternalServerError(format!("Invalid OIDC redirect URI: {err}"))
})?;
let client_secret = oidc.client_secret.clone().ok_or_else(|| {
ApiError::InternalServerError("OIDC client secret is missing".to_string())
})?;
let client = CoreClient::from_provider_metadata(
discovery.metadata.clone(),
ClientId::new(oidc.client_id.clone()),
Some(ClientSecret::new(client_secret)),
)
.set_redirect_uri(redirect_uri);
let redirect_target = sanitize_redirect_target(redirect_to);
let pkce = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_state, nonce) = client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.add_scope(Scope::new("openid".to_string()))
.add_scope(Scope::new("email".to_string()))
.add_scope(Scope::new("profile".to_string()))
.add_scopes(
oidc.scopes
.iter()
.filter(|scope| !matches!(scope.as_str(), "openid" | "email" | "profile"))
.cloned()
.map(Scope::new),
)
.set_pkce_challenge(pkce.0)
.url();
Ok(OidcLoginRedirect {
authorization_url: auth_url.to_string(),
cookies: vec![
build_cookie(
state,
OIDC_STATE_COOKIE_NAME,
csrf_state.secret().to_string(),
600,
true,
),
build_cookie(
state,
OIDC_NONCE_COOKIE_NAME,
nonce.secret().to_string(),
600,
true,
),
build_cookie(
state,
OIDC_PKCE_COOKIE_NAME,
pkce.1.secret().to_string(),
600,
true,
),
build_cookie(
state,
OIDC_REDIRECT_COOKIE_NAME,
redirect_target,
600,
false,
),
],
})
}
pub async fn handle_callback(
state: &SharedState,
headers: &HeaderMap,
query: &OidcCallbackQuery,
) -> Result<OidcAuthenticatedIdentity, ApiError> {
if let Some(error) = &query.error {
let description = query
.error_description
.as_deref()
.unwrap_or("OpenID Connect login failed");
return Err(ApiError::Unauthorized(format!("{error}: {description}")));
}
let code = query
.code
.as_ref()
.ok_or_else(|| ApiError::BadRequest("Missing authorization code".to_string()))?;
let returned_state = query
.state
.as_ref()
.ok_or_else(|| ApiError::BadRequest("Missing OIDC state".to_string()))?;
let expected_state = get_cookie_value(headers, OIDC_STATE_COOKIE_NAME)
.ok_or_else(|| ApiError::Unauthorized("Missing OIDC state cookie".to_string()))?;
let expected_nonce = get_cookie_value(headers, OIDC_NONCE_COOKIE_NAME)
.ok_or_else(|| ApiError::Unauthorized("Missing OIDC nonce cookie".to_string()))?;
let pkce_verifier = get_cookie_value(headers, OIDC_PKCE_COOKIE_NAME)
.ok_or_else(|| ApiError::Unauthorized("Missing OIDC PKCE verifier cookie".to_string()))?;
if returned_state != &expected_state {
return Err(ApiError::Unauthorized(
"OIDC state validation failed".to_string(),
));
}
let oidc = oidc_config(state)?;
let discovery = fetch_discovery_document(&oidc).await?;
let http_client = OidcHttpClient::builder()
.redirect(openidconnect::reqwest::redirect::Policy::none())
.build()
.map_err(|err| {
ApiError::InternalServerError(format!("Failed to build OIDC HTTP client: {err}"))
})?;
let redirect_uri = RedirectUrl::new(oidc.redirect_uri.clone()).map_err(|err| {
ApiError::InternalServerError(format!("Invalid OIDC redirect URI: {err}"))
})?;
let client_secret = oidc.client_secret.clone().ok_or_else(|| {
ApiError::InternalServerError("OIDC client secret is missing".to_string())
})?;
let client = CoreClient::from_provider_metadata(
discovery.metadata.clone(),
ClientId::new(oidc.client_id.clone()),
Some(ClientSecret::new(client_secret)),
)
.set_redirect_uri(redirect_uri);
let token_response = client
.exchange_code(AuthorizationCode::new(code.clone()))
.map_err(|err| {
ApiError::InternalServerError(format!("OIDC token request is misconfigured: {err}"))
})?
.set_pkce_verifier(PkceCodeVerifier::new(pkce_verifier))
.request_async(&http_client)
.await
.map_err(|err| ApiError::Unauthorized(format!("OIDC token exchange failed: {err}")))?;
let id_token = token_response.id_token().ok_or_else(|| {
ApiError::Unauthorized("OIDC provider did not return an ID token".to_string())
})?;
let raw_id_token = id_token.to_string();
let claims = verify_id_token(&raw_id_token, &discovery, &oidc, &expected_nonce).await?;
let mut oidc_claims = OidcIdentityClaims {
issuer: claims.iss,
sub: claims.sub,
email: claims.email,
email_verified: claims.email_verified,
name: claims.name,
preferred_username: claims.preferred_username,
groups: claims.groups,
};
if let Ok(userinfo_request) = client.user_info(token_response.access_token().to_owned(), None) {
if let Ok(userinfo) = userinfo_request.request_async(&http_client).await {
merge_userinfo_claims(&mut oidc_claims, &userinfo);
}
}
let identity = upsert_identity(state, &oidc_claims).await?;
if identity.frozen {
return Err(ApiError::Forbidden(
"Identity is frozen and cannot authenticate".to_string(),
));
}
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
let token_response = TokenResponse::new(
access_token,
refresh_token,
state.jwt_config.access_token_expiration,
)
.with_user(
identity.id,
identity.login.clone(),
identity.display_name.clone(),
);
Ok(OidcAuthenticatedIdentity {
current_user: CurrentUserResponse {
id: identity.id,
login: identity.login.clone(),
display_name: identity.display_name.clone(),
},
id_token: raw_id_token,
token_response,
})
}
pub async fn build_logout_redirect(
state: &SharedState,
headers: &HeaderMap,
) -> Result<OidcLogoutRedirect, ApiError> {
let oidc = oidc_config(state)?;
let discovery = fetch_discovery_document(&oidc).await?;
let post_logout_redirect_uri = oidc
.post_logout_redirect_uri
.clone()
.unwrap_or_else(|| "/login".to_string());
let redirect_url = if let Some(end_session_endpoint) = discovery.end_session_endpoint {
let mut url = Url::parse(&end_session_endpoint).map_err(|err| {
ApiError::InternalServerError(format!("Invalid end_session_endpoint: {err}"))
})?;
{
let mut pairs = url.query_pairs_mut();
if let Some(id_token_hint) = get_cookie_value(headers, OIDC_ID_TOKEN_COOKIE_NAME) {
pairs.append_pair("id_token_hint", &id_token_hint);
}
pairs.append_pair("post_logout_redirect_uri", &post_logout_redirect_uri);
pairs.append_pair("client_id", &oidc.client_id);
}
String::from(url)
} else {
post_logout_redirect_uri
};
Ok(OidcLogoutRedirect {
redirect_url,
cookies: clear_auth_cookies(state),
})
}
pub fn clear_auth_cookies(state: &SharedState) -> Vec<Cookie<'static>> {
[
ACCESS_COOKIE_NAME,
REFRESH_COOKIE_NAME,
OIDC_ID_TOKEN_COOKIE_NAME,
OIDC_STATE_COOKIE_NAME,
OIDC_NONCE_COOKIE_NAME,
OIDC_PKCE_COOKIE_NAME,
OIDC_REDIRECT_COOKIE_NAME,
]
.into_iter()
.map(|name| remove_cookie(state, name))
.collect()
}
pub fn build_auth_cookies(
state: &SharedState,
token_response: &TokenResponse,
id_token: &str,
) -> Vec<Cookie<'static>> {
let mut cookies = vec![
build_cookie(
state,
ACCESS_COOKIE_NAME,
token_response.access_token.clone(),
state.jwt_config.access_token_expiration,
true,
),
build_cookie(
state,
REFRESH_COOKIE_NAME,
token_response.refresh_token.clone(),
state.jwt_config.refresh_token_expiration,
true,
),
];
if !id_token.is_empty() {
cookies.push(build_cookie(
state,
OIDC_ID_TOKEN_COOKIE_NAME,
id_token.to_string(),
state.jwt_config.refresh_token_expiration,
true,
));
}
cookies
}
pub fn apply_cookies_to_headers(
headers: &mut HeaderMap,
cookies: &[Cookie<'static>],
) -> Result<(), ApiError> {
for cookie in cookies {
let value = HeaderValue::from_str(&cookie.to_string()).map_err(|err| {
ApiError::InternalServerError(format!("Failed to serialize cookie header: {err}"))
})?;
headers.append(header::SET_COOKIE, value);
}
Ok(())
}
pub fn oidc_callback_redirect_response(
state: &SharedState,
token_response: &TokenResponse,
redirect_to: Option<String>,
id_token: &str,
) -> Result<Response, ApiError> {
let redirect_target = sanitize_redirect_target(redirect_to.as_deref());
let redirect_url = format!(
"{LOGIN_CALLBACK_PATH}#access_token={}&refresh_token={}&expires_in={}&redirect_to={}",
encode_fragment_value(&token_response.access_token),
encode_fragment_value(&token_response.refresh_token),
token_response.expires_in,
encode_fragment_value(&redirect_target),
);
let mut response = Redirect::temporary(&redirect_url).into_response();
let mut cookies = build_auth_cookies(state, token_response, id_token);
cookies.push(remove_cookie(state, OIDC_STATE_COOKIE_NAME));
cookies.push(remove_cookie(state, OIDC_NONCE_COOKIE_NAME));
cookies.push(remove_cookie(state, OIDC_PKCE_COOKIE_NAME));
cookies.push(remove_cookie(state, OIDC_REDIRECT_COOKIE_NAME));
apply_cookies_to_headers(response.headers_mut(), &cookies)?;
Ok(response)
}
pub fn cookie_authenticated_user(
headers: &HeaderMap,
state: &SharedState,
) -> Result<Option<crate::auth::middleware::AuthenticatedUser>, ApiError> {
let Some(token) = get_cookie_value(headers, ACCESS_COOKIE_NAME) else {
return Ok(None);
};
let claims = validate_token(&token, &state.jwt_config).map_err(ApiError::from)?;
Ok(Some(crate::auth::middleware::AuthenticatedUser { claims }))
}
pub fn get_cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(';'))
.filter_map(|part| {
let mut pieces = part.trim().splitn(2, '=');
let key = pieces.next()?.trim();
let value = pieces.next()?.trim();
if key == name {
Some(value.to_string())
} else {
None
}
})
.next()
}
fn oidc_config(state: &SharedState) -> Result<OidcConfig, ApiError> {
state
.config
.security
.oidc
.clone()
.filter(|oidc| oidc.enabled)
.ok_or_else(|| {
ApiError::NotImplemented("OIDC authentication is not configured".to_string())
})
}
async fn fetch_discovery_document(oidc: &OidcConfig) -> Result<OidcDiscoveryDocument, ApiError> {
let discovery = reqwest::get(&oidc.discovery_url).await.map_err(|err| {
ApiError::InternalServerError(format!("Failed to fetch OIDC discovery document: {err}"))
})?;
if !discovery.status().is_success() {
return Err(ApiError::InternalServerError(format!(
"OIDC discovery request failed with status {}",
discovery.status()
)));
}
discovery
.json::<OidcDiscoveryDocument>()
.await
.map_err(|err| {
ApiError::InternalServerError(format!("Failed to parse OIDC discovery document: {err}"))
})
}
async fn upsert_identity(
state: &SharedState,
oidc_claims: &OidcIdentityClaims,
) -> Result<attune_common::models::identity::Identity, ApiError> {
let existing_by_subject =
IdentityRepository::find_by_oidc_subject(&state.db, &oidc_claims.issuer, &oidc_claims.sub)
.await?;
let desired_login = derive_login(oidc_claims);
let display_name = derive_display_name(oidc_claims);
let attributes = json!({
"oidc": oidc_claims,
});
match existing_by_subject {
Some(identity) => {
let updated = UpdateIdentityInput {
display_name,
password_hash: None,
attributes: Some(attributes.clone()),
frozen: None,
};
let identity = IdentityRepository::update(&state.db, identity.id, updated)
.await
.map_err(ApiError::from)?;
sync_roles(&state.db, identity.id, "oidc", &oidc_claims.groups).await?;
Ok(identity)
}
None => {
let login = match IdentityRepository::find_by_login(&state.db, &desired_login).await? {
Some(_) => fallback_subject_login(oidc_claims),
None => desired_login,
};
let identity = IdentityRepository::create(
&state.db,
CreateIdentityInput {
login,
display_name,
password_hash: None,
attributes,
},
)
.await
.map_err(ApiError::from)?;
sync_roles(&state.db, identity.id, "oidc", &oidc_claims.groups).await?;
Ok(identity)
}
}
}
async fn sync_roles(
db: &sqlx::PgPool,
identity_id: i64,
source: &str,
roles: &[String],
) -> Result<(), ApiError> {
IdentityRoleAssignmentRepository::replace_managed_roles(db, identity_id, source, roles)
.await
.map_err(Into::into)
}
fn derive_login(oidc_claims: &OidcIdentityClaims) -> String {
oidc_claims
.email
.clone()
.or_else(|| oidc_claims.preferred_username.clone())
.unwrap_or_else(|| fallback_subject_login(oidc_claims))
}
async fn verify_id_token(
raw_id_token: &str,
discovery: &OidcDiscoveryDocument,
oidc: &OidcConfig,
expected_nonce: &str,
) -> Result<VerifiedIdTokenClaims, ApiError> {
let header = decode_header(raw_id_token).map_err(|err| {
ApiError::Unauthorized(format!("OIDC ID token header decode failed: {err}"))
})?;
let algorithm = match header.alg {
Algorithm::RS256 => Algorithm::RS256,
Algorithm::RS384 => Algorithm::RS384,
Algorithm::RS512 => Algorithm::RS512,
other => {
return Err(ApiError::Unauthorized(format!(
"OIDC ID token uses unsupported signing algorithm: {other:?}"
)))
}
};
let jwks = reqwest::get(discovery.metadata.jwks_uri().url().as_str())
.await
.map_err(|err| ApiError::InternalServerError(format!("Failed to fetch OIDC JWKS: {err}")))?
.json::<JwkSet>()
.await
.map_err(|err| {
ApiError::InternalServerError(format!("Failed to parse OIDC JWKS: {err}"))
})?;
let jwk = jwks
.keys
.iter()
.find(|jwk| {
jwk.common.key_id == header.kid
&& matches!(
jwk.common.public_key_use,
Some(jsonwebtoken::jwk::PublicKeyUse::Signature)
)
&& matches!(
jwk.algorithm,
AlgorithmParameters::RSA(_) | AlgorithmParameters::EllipticCurve(_)
)
})
.ok_or_else(|| ApiError::Unauthorized("OIDC signing key not found in JWKS".to_string()))?;
let decoding_key = DecodingKey::from_jwk(jwk)
.map_err(|err| ApiError::Unauthorized(format!("OIDC JWK decode failed: {err}")))?;
let issuer = discovery.metadata.issuer().to_string();
let mut validation = Validation::new(algorithm);
validation.set_issuer(&[issuer.as_str()]);
validation.set_audience(&[oidc.client_id.as_str()]);
validation.set_required_spec_claims(&["exp", "iat", "iss", "sub", "aud"]);
validation.validate_nbf = false;
let token = decode::<VerifiedIdTokenClaims>(raw_id_token, &decoding_key, &validation)
.map_err(|err| ApiError::Unauthorized(format!("OIDC ID token validation failed: {err}")))?;
if token.claims.nonce.as_deref() != Some(expected_nonce) {
return Err(ApiError::Unauthorized(
"OIDC nonce validation failed".to_string(),
));
}
Ok(token.claims)
}
fn derive_display_name(oidc_claims: &OidcIdentityClaims) -> Option<String> {
oidc_claims
.name
.clone()
.or_else(|| oidc_claims.preferred_username.clone())
.or_else(|| oidc_claims.email.clone())
}
fn fallback_subject_login(oidc_claims: &OidcIdentityClaims) -> String {
let mut hasher = Sha256::new();
hasher.update(oidc_claims.issuer.as_bytes());
hasher.update(b":");
hasher.update(oidc_claims.sub.as_bytes());
let digest = hex::encode(hasher.finalize());
format!("oidc:{}", &digest[..24])
}
fn extract_groups_from_claims<T>(claims: &T) -> Vec<String>
where
T: Serialize,
{
let Ok(json) = serde_json::to_value(claims) else {
return Vec::new();
};
match json.get("groups") {
Some(JsonValue::Array(values)) => values
.iter()
.filter_map(|value| value.as_str().map(ToString::to_string))
.collect(),
Some(JsonValue::String(value)) => vec![value.to_string()],
_ => Vec::new(),
}
}
fn merge_userinfo_claims(oidc_claims: &mut OidcIdentityClaims, userinfo: &CoreUserInfoClaims) {
if oidc_claims.email.is_none() {
oidc_claims.email = userinfo.email().map(|email| email.as_str().to_string());
}
if oidc_claims.name.is_none() {
oidc_claims.name = userinfo.name().and_then(first_localized_claim);
}
if oidc_claims.preferred_username.is_none() {
oidc_claims.preferred_username = userinfo
.preferred_username()
.map(|username| username.as_str().to_string());
}
if oidc_claims.groups.is_empty() {
oidc_claims.groups = extract_groups_from_claims(userinfo.additional_claims());
}
}
fn first_localized_claim<T>(claim: &LocalizedClaim<T>) -> Option<String>
where
T: std::ops::Deref<Target = String>,
{
claim
.iter()
.next()
.map(|(_, value)| value.as_str().to_string())
}
fn build_cookie(
state: &SharedState,
name: &'static str,
value: String,
max_age_seconds: i64,
http_only: bool,
) -> Cookie<'static> {
let mut cookie = Cookie::build((name, value))
.path("/")
.same_site(SameSite::Lax)
.http_only(http_only)
.max_age(CookieDuration::seconds(max_age_seconds))
.build();
if should_use_secure_cookies(state) {
cookie.set_secure(true);
}
cookie
}
fn remove_cookie(state: &SharedState, name: &'static str) -> Cookie<'static> {
let mut cookie = Cookie::build((name, String::new()))
.path("/")
.same_site(SameSite::Lax)
.http_only(true)
.max_age(CookieDuration::seconds(0))
.build();
cookie.make_removal();
if should_use_secure_cookies(state) {
cookie.set_secure(true);
}
cookie
}
fn should_use_secure_cookies(state: &SharedState) -> bool {
state.config.is_production()
|| state
.config
.security
.oidc
.as_ref()
.map(|oidc| oidc.redirect_uri.starts_with("https://"))
.unwrap_or(false)
}
fn sanitize_redirect_target(redirect_to: Option<&str>) -> String {
let fallback = "/".to_string();
let Some(redirect_to) = redirect_to else {
return fallback;
};
if redirect_to.starts_with('/') && !redirect_to.starts_with("//") {
redirect_to.to_string()
} else {
fallback
}
}
pub fn unauthorized_redirect(location: &str) -> Response {
let mut response = Redirect::to(location).into_response();
*response.status_mut() = StatusCode::FOUND;
response
}
fn encode_fragment_value(value: &str) -> String {
byte_serialize(value.as_bytes()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_redirect_target_rejects_external_urls() {
assert_eq!(sanitize_redirect_target(Some("https://example.com")), "/");
assert_eq!(sanitize_redirect_target(Some("//example.com")), "/");
assert_eq!(
sanitize_redirect_target(Some("/executions/42")),
"/executions/42"
);
}
#[test]
fn extract_groups_from_claims_accepts_array_and_string() {
let array_claims = serde_json::json!({ "groups": ["admins", "operators"] });
let string_claims = serde_json::json!({ "groups": "admins" });
assert_eq!(
extract_groups_from_claims(&array_claims),
vec!["admins".to_string(), "operators".to_string()]
);
assert_eq!(
extract_groups_from_claims(&string_claims),
vec!["admins".to_string()]
);
}
}

154
crates/api/src/authz.rs Normal file
View File

@@ -0,0 +1,154 @@
//! RBAC authorization service for API handlers.
//!
//! This module evaluates grants assigned to user identities via
//! `permission_set` and `permission_assignment`.
use crate::{
auth::{jwt::TokenType, middleware::AuthenticatedUser},
middleware::ApiError,
};
use attune_common::{
rbac::{Action, AuthorizationContext, Grant, Resource},
repositories::{
identity::{IdentityRepository, IdentityRoleAssignmentRepository, PermissionSetRepository},
FindById,
},
};
use sqlx::PgPool;
#[derive(Debug, Clone)]
pub struct AuthorizationCheck {
pub resource: Resource,
pub action: Action,
pub context: AuthorizationContext,
}
#[derive(Clone)]
pub struct AuthorizationService {
db: PgPool,
}
impl AuthorizationService {
pub fn new(db: PgPool) -> Self {
Self { db }
}
pub async fn authorize(
&self,
user: &AuthenticatedUser,
mut check: AuthorizationCheck,
) -> Result<(), ApiError> {
// Non-access tokens are governed by dedicated scope checks in route logic.
// They are not evaluated through identity RBAC grants.
if user.claims.token_type != TokenType::Access {
return Ok(());
}
let identity_id = user.identity_id().map_err(|_| {
ApiError::Unauthorized("Invalid authentication subject in access token".to_string())
})?;
// Ensure identity exists and load identity attributes used by attribute constraints.
let identity = IdentityRepository::find_by_id(&self.db, identity_id)
.await?
.ok_or_else(|| ApiError::Unauthorized("Identity not found".to_string()))?;
check.context.identity_id = identity_id;
check.context.identity_attributes = match identity.attributes {
serde_json::Value::Object(map) => map.into_iter().collect(),
_ => Default::default(),
};
let grants = self.load_effective_grants(identity_id).await?;
let allowed = Self::is_allowed(&grants, check.resource, check.action, &check.context);
if !allowed {
return Err(ApiError::Forbidden(format!(
"Insufficient permissions: {}:{}",
resource_name(check.resource),
action_name(check.action)
)));
}
Ok(())
}
pub async fn effective_grants(&self, user: &AuthenticatedUser) -> Result<Vec<Grant>, ApiError> {
if user.claims.token_type != TokenType::Access {
return Ok(Vec::new());
}
let identity_id = user.identity_id().map_err(|_| {
ApiError::Unauthorized("Invalid authentication subject in access token".to_string())
})?;
self.load_effective_grants(identity_id).await
}
pub fn is_allowed(
grants: &[Grant],
resource: Resource,
action: Action,
context: &AuthorizationContext,
) -> bool {
grants.iter().any(|g| g.allows(resource, action, context))
}
async fn load_effective_grants(&self, identity_id: i64) -> Result<Vec<Grant>, ApiError> {
let mut permission_sets =
PermissionSetRepository::find_by_identity(&self.db, identity_id).await?;
let roles =
IdentityRoleAssignmentRepository::find_role_names_by_identity(&self.db, identity_id)
.await?;
let role_permission_sets = PermissionSetRepository::find_by_roles(&self.db, &roles).await?;
permission_sets.extend(role_permission_sets);
let mut seen_permission_sets = std::collections::HashSet::new();
permission_sets.retain(|permission_set| seen_permission_sets.insert(permission_set.id));
let mut grants = Vec::new();
for permission_set in permission_sets {
let set_grants: Vec<Grant> =
serde_json::from_value(permission_set.grants).map_err(|e| {
ApiError::InternalServerError(format!(
"Invalid grant schema in permission set '{}': {}",
permission_set.r#ref, e
))
})?;
grants.extend(set_grants);
}
Ok(grants)
}
}
fn resource_name(resource: Resource) -> &'static str {
match resource {
Resource::Packs => "packs",
Resource::Actions => "actions",
Resource::Rules => "rules",
Resource::Triggers => "triggers",
Resource::Executions => "executions",
Resource::Events => "events",
Resource::Enforcements => "enforcements",
Resource::Inquiries => "inquiries",
Resource::Keys => "keys",
Resource::Artifacts => "artifacts",
Resource::Identities => "identities",
Resource::Permissions => "permissions",
}
}
fn action_name(action: Action) -> &'static str {
match action {
Action::Read => "read",
Action::Create => "create",
Action::Update => "update",
Action::Delete => "delete",
Action::Execute => "execute",
Action::Cancel => "cancel",
Action::Respond => "respond",
Action::Manage => "manage",
Action::Decrypt => "decrypt",
}
}

View File

@@ -25,9 +25,8 @@ pub struct CreateActionRequest {
pub label: String, pub label: String,
/// Action description /// Action description
#[validate(length(min = 1))]
#[schema(example = "Posts a message to a Slack channel")] #[schema(example = "Posts a message to a Slack channel")]
pub description: String, pub description: Option<String>,
/// Entry point for action execution (e.g., path to script, function name) /// Entry point for action execution (e.g., path to script, function name)
#[validate(length(min = 1, max = 1024))] #[validate(length(min = 1, max = 1024))]
@@ -63,7 +62,6 @@ pub struct UpdateActionRequest {
pub label: Option<String>, pub label: Option<String>,
/// Action description /// Action description
#[validate(length(min = 1))]
#[schema(example = "Posts a message to a Slack channel with enhanced features")] #[schema(example = "Posts a message to a Slack channel with enhanced features")]
pub description: Option<String>, pub description: Option<String>,
@@ -76,9 +74,8 @@ pub struct UpdateActionRequest {
#[schema(example = 1)] #[schema(example = 1)]
pub runtime: Option<i64>, pub runtime: Option<i64>,
/// Optional semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0") /// Optional semver version constraint patch for the runtime.
#[schema(example = ">=3.12", nullable = true)] pub runtime_version_constraint: Option<RuntimeVersionConstraintPatch>,
pub runtime_version_constraint: Option<Option<String>>,
/// Parameter schema (StackStorm-style with inline required/secret) /// Parameter schema (StackStorm-style with inline required/secret)
#[schema(value_type = Object, nullable = true)] #[schema(value_type = Object, nullable = true)]
@@ -89,6 +86,14 @@ pub struct UpdateActionRequest {
pub out_schema: Option<JsonValue>, pub out_schema: Option<JsonValue>,
} }
/// Explicit patch operation for a nullable runtime version constraint.
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
#[serde(tag = "op", content = "value", rename_all = "snake_case")]
pub enum RuntimeVersionConstraintPatch {
Set(String),
Clear,
}
/// Response DTO for action information /// Response DTO for action information
#[derive(Debug, Clone, Serialize, ToSchema)] #[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ActionResponse { pub struct ActionResponse {
@@ -114,7 +119,7 @@ pub struct ActionResponse {
/// Action description /// Action description
#[schema(example = "Posts a message to a Slack channel")] #[schema(example = "Posts a message to a Slack channel")]
pub description: String, pub description: Option<String>,
/// Entry point /// Entry point
#[schema(example = "/actions/slack/post_message.py")] #[schema(example = "/actions/slack/post_message.py")]
@@ -176,7 +181,7 @@ pub struct ActionSummary {
/// Action description /// Action description
#[schema(example = "Posts a message to a Slack channel")] #[schema(example = "Posts a message to a Slack channel")]
pub description: String, pub description: Option<String>,
/// Entry point /// Entry point
#[schema(example = "/actions/slack/post_message.py")] #[schema(example = "/actions/slack/post_message.py")]
@@ -314,7 +319,7 @@ mod tests {
r#ref: "".to_string(), // Invalid: empty r#ref: "".to_string(), // Invalid: empty
pack_ref: "test-pack".to_string(), pack_ref: "test-pack".to_string(),
label: "Test Action".to_string(), label: "Test Action".to_string(),
description: "Test description".to_string(), description: Some("Test description".to_string()),
entrypoint: "/actions/test.py".to_string(), entrypoint: "/actions/test.py".to_string(),
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,
@@ -331,7 +336,7 @@ mod tests {
r#ref: "test.action".to_string(), r#ref: "test.action".to_string(),
pack_ref: "test-pack".to_string(), pack_ref: "test-pack".to_string(),
label: "Test Action".to_string(), label: "Test Action".to_string(),
description: "Test description".to_string(), description: Some("Test description".to_string()),
entrypoint: "/actions/test.py".to_string(), entrypoint: "/actions/test.py".to_string(),
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,

View File

@@ -97,19 +97,41 @@ pub struct UpdateArtifactRequest {
pub retention_limit: Option<i32>, pub retention_limit: Option<i32>,
/// Updated name /// Updated name
pub name: Option<String>, pub name: Option<ArtifactStringPatch>,
/// Updated description /// Updated description
pub description: Option<String>, pub description: Option<ArtifactStringPatch>,
/// Updated content type /// Updated content type
pub content_type: Option<String>, pub content_type: Option<ArtifactStringPatch>,
/// Updated execution ID (re-links artifact to a different execution) /// Updated execution patch (set a new execution ID or clear the link)
pub execution: Option<i64>, pub execution: Option<ArtifactExecutionPatch>,
/// Updated structured data (replaces existing data entirely) /// Updated structured data (replaces existing data entirely)
pub data: Option<JsonValue>, pub data: Option<ArtifactJsonPatch>,
}
/// Explicit patch operation for a nullable execution link.
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
#[serde(tag = "op", content = "value", rename_all = "snake_case")]
pub enum ArtifactExecutionPatch {
Set(i64),
Clear,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
#[serde(tag = "op", content = "value", rename_all = "snake_case")]
pub enum ArtifactStringPatch {
Set(String),
Clear,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
#[serde(tag = "op", content = "value", rename_all = "snake_case")]
pub enum ArtifactJsonPatch {
Set(JsonValue),
Clear,
} }
/// Request DTO for appending to a progress-type artifact /// Request DTO for appending to a progress-type artifact

View File

@@ -136,3 +136,63 @@ pub struct CurrentUserResponse {
#[schema(example = "Administrator")] #[schema(example = "Administrator")]
pub display_name: Option<String>, pub display_name: Option<String>,
} }
/// Public authentication settings for the login page.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AuthSettingsResponse {
/// Whether authentication is enabled for the server.
#[schema(example = true)]
pub authentication_enabled: bool,
/// Whether local username/password login is configured.
#[schema(example = true)]
pub local_password_enabled: bool,
/// Whether local username/password login should be shown by default.
#[schema(example = true)]
pub local_password_visible_by_default: bool,
/// Whether OIDC login is configured and enabled.
#[schema(example = false)]
pub oidc_enabled: bool,
/// Whether OIDC login should be shown by default.
#[schema(example = false)]
pub oidc_visible_by_default: bool,
/// Provider name for `?auth=<provider>`.
#[schema(example = "sso")]
pub oidc_provider_name: Option<String>,
/// User-facing provider label for the login button.
#[schema(example = "Example SSO")]
pub oidc_provider_label: Option<String>,
/// Optional icon URL shown beside the provider label.
#[schema(example = "https://auth.example.com/assets/logo.svg")]
pub oidc_provider_icon_url: Option<String>,
/// Whether LDAP login is configured and enabled.
#[schema(example = false)]
pub ldap_enabled: bool,
/// Whether LDAP login should be shown by default.
#[schema(example = false)]
pub ldap_visible_by_default: bool,
/// Provider name for `?auth=<provider>`.
#[schema(example = "ldap")]
pub ldap_provider_name: Option<String>,
/// User-facing provider label for the login button.
#[schema(example = "Company LDAP")]
pub ldap_provider_label: Option<String>,
/// Optional icon URL shown beside the provider label.
#[schema(example = "https://ldap.example.com/assets/logo.svg")]
pub ldap_provider_icon_url: Option<String>,
/// Whether unauthenticated self-service registration is allowed.
#[schema(example = false)]
pub self_registration_enabled: bool,
}

View File

@@ -52,10 +52,14 @@ pub struct ExecutionResponse {
#[schema(example = 1)] #[schema(example = 1)]
pub enforcement: Option<i64>, pub enforcement: Option<i64>,
/// Executor ID (worker/executor that ran this) /// Identity ID that initiated this execution
#[schema(example = 1)] #[schema(example = 1)]
pub executor: Option<i64>, pub executor: Option<i64>,
/// Worker ID currently assigned to this execution
#[schema(example = 1)]
pub worker: Option<i64>,
/// Execution status /// Execution status
#[schema(example = "succeeded")] #[schema(example = "succeeded")]
pub status: ExecutionStatus, pub status: ExecutionStatus,
@@ -216,6 +220,7 @@ impl From<attune_common::models::execution::Execution> for ExecutionResponse {
parent: execution.parent, parent: execution.parent,
enforcement: execution.enforcement, enforcement: execution.enforcement,
executor: execution.executor, executor: execution.executor,
worker: execution.worker,
status: execution.status, status: execution.status,
result: execution result: execution
.result .result

View File

@@ -2,6 +2,7 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use utoipa::{IntoParams, ToSchema}; use utoipa::{IntoParams, ToSchema};
use validator::Validate; use validator::Validate;
@@ -61,9 +62,9 @@ pub struct KeyResponse {
#[schema(example = true)] #[schema(example = true)]
pub encrypted: bool, pub encrypted: bool,
/// The secret value (decrypted if encrypted) /// The secret value (decrypted if encrypted). Can be a string, object, array, number, or boolean.
#[schema(example = "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")] #[schema(value_type = Value, example = json!("ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"))]
pub value: String, pub value: JsonValue,
/// Creation timestamp /// Creation timestamp
#[schema(example = "2024-01-13T10:30:00Z")] #[schema(example = "2024-01-13T10:30:00Z")]
@@ -194,21 +195,16 @@ pub struct CreateKeyRequest {
#[schema(example = "GitHub API Token")] #[schema(example = "GitHub API Token")]
pub name: String, pub name: String,
/// The secret value to store /// The secret value to store. Can be a string, object, array, number, or boolean.
#[validate(length(min = 1, max = 10000))] #[schema(value_type = Value, example = json!("ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"))]
#[schema(example = "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")] pub value: JsonValue,
pub value: String,
/// Whether to encrypt the value (recommended: true) /// Whether to encrypt the value at rest (default: false; use --encrypt / -e from CLI)
#[serde(default = "default_encrypted")] #[serde(default)]
#[schema(example = true)] #[schema(example = false)]
pub encrypted: bool, pub encrypted: bool,
} }
fn default_encrypted() -> bool {
true
}
/// Request to update an existing key/secret /// Request to update an existing key/secret
#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)]
pub struct UpdateKeyRequest { pub struct UpdateKeyRequest {
@@ -217,10 +213,9 @@ pub struct UpdateKeyRequest {
#[schema(example = "GitHub API Token (Updated)")] #[schema(example = "GitHub API Token (Updated)")]
pub name: Option<String>, pub name: Option<String>,
/// Update the secret value /// Update the secret value. Can be a string, object, array, number, or boolean.
#[validate(length(min = 1, max = 10000))] #[schema(value_type = Option<Value>, example = json!("ghp_new_token_xxxxxxxxxxxxxxxxxxxxxxxx"))]
#[schema(example = "ghp_new_token_xxxxxxxxxxxxxxxxxxxxxxxx")] pub value: Option<JsonValue>,
pub value: Option<String>,
/// Update encryption status (re-encrypts if changing from false to true) /// Update encryption status (re-encrypts if changing from false to true)
#[schema(example = true)] #[schema(example = true)]

View File

@@ -11,7 +11,9 @@ pub mod history;
pub mod inquiry; pub mod inquiry;
pub mod key; pub mod key;
pub mod pack; pub mod pack;
pub mod permission;
pub mod rule; pub mod rule;
pub mod runtime;
pub mod trigger; pub mod trigger;
pub mod webhook; pub mod webhook;
pub mod workflow; pub mod workflow;
@@ -28,8 +30,8 @@ pub use artifact::{
CreateVersionJsonRequest, SetDataRequest, UpdateArtifactRequest, CreateVersionJsonRequest, SetDataRequest, UpdateArtifactRequest,
}; };
pub use auth::{ pub use auth::{
ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest, RegisterRequest, AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest,
TokenResponse, RefreshTokenRequest, RegisterRequest, TokenResponse,
}; };
pub use common::{ pub use common::{
ApiResponse, PaginatedResponse, PaginationMeta, PaginationParams, SuccessResponse, ApiResponse, PaginatedResponse, PaginationMeta, PaginationParams, SuccessResponse,
@@ -48,7 +50,14 @@ pub use inquiry::{
}; };
pub use key::{CreateKeyRequest, KeyQueryParams, KeyResponse, KeySummary, UpdateKeyRequest}; pub use key::{CreateKeyRequest, KeyQueryParams, KeyResponse, KeySummary, UpdateKeyRequest};
pub use pack::{CreatePackRequest, PackResponse, PackSummary, UpdatePackRequest}; pub use pack::{CreatePackRequest, PackResponse, PackSummary, UpdatePackRequest};
pub use permission::{
CreateIdentityRequest, CreateIdentityRoleAssignmentRequest, CreatePermissionAssignmentRequest,
CreatePermissionSetRoleAssignmentRequest, IdentityResponse, IdentityRoleAssignmentResponse,
IdentitySummary, PermissionAssignmentResponse, PermissionSetQueryParams,
PermissionSetRoleAssignmentResponse, PermissionSetSummary, UpdateIdentityRequest,
};
pub use rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest}; pub use rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest};
pub use runtime::{CreateRuntimeRequest, RuntimeResponse, RuntimeSummary, UpdateRuntimeRequest};
pub use trigger::{ pub use trigger::{
CreateSensorRequest, CreateTriggerRequest, SensorResponse, SensorSummary, TriggerResponse, CreateSensorRequest, CreateTriggerRequest, SensorResponse, SensorSummary, TriggerResponse,
TriggerSummary, UpdateSensorRequest, UpdateTriggerRequest, TriggerSummary, UpdateSensorRequest, UpdateTriggerRequest,

View File

@@ -129,7 +129,7 @@ pub struct UpdatePackRequest {
/// Pack description /// Pack description
#[schema(example = "Enhanced Slack integration with new features")] #[schema(example = "Enhanced Slack integration with new features")]
pub description: Option<String>, pub description: Option<PackDescriptionPatch>,
/// Pack version /// Pack version
#[validate(length(min = 1, max = 50))] #[validate(length(min = 1, max = 50))]
@@ -165,6 +165,13 @@ pub struct UpdatePackRequest {
pub is_standard: Option<bool>, pub is_standard: Option<bool>,
} }
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
#[serde(tag = "op", content = "value", rename_all = "snake_case")]
pub enum PackDescriptionPatch {
Set(String),
Clear,
}
/// Response DTO for pack information /// Response DTO for pack information
#[derive(Debug, Clone, Serialize, ToSchema)] #[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PackResponse { pub struct PackResponse {

View File

@@ -0,0 +1,110 @@
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use utoipa::{IntoParams, ToSchema};
use validator::Validate;
#[derive(Debug, Clone, Deserialize, IntoParams)]
pub struct PermissionSetQueryParams {
#[serde(default)]
pub pack_ref: Option<String>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct IdentitySummary {
pub id: i64,
pub login: String,
pub display_name: Option<String>,
pub frozen: bool,
pub attributes: JsonValue,
pub roles: Vec<String>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct IdentityRoleAssignmentResponse {
pub id: i64,
pub identity_id: i64,
pub role: String,
pub source: String,
pub managed: bool,
pub created: chrono::DateTime<chrono::Utc>,
pub updated: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct IdentityResponse {
pub id: i64,
pub login: String,
pub display_name: Option<String>,
pub frozen: bool,
pub attributes: JsonValue,
pub roles: Vec<IdentityRoleAssignmentResponse>,
pub direct_permissions: Vec<PermissionAssignmentResponse>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PermissionSetSummary {
pub id: i64,
pub r#ref: String,
pub pack_ref: Option<String>,
pub label: Option<String>,
pub description: Option<String>,
pub grants: JsonValue,
pub roles: Vec<PermissionSetRoleAssignmentResponse>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PermissionAssignmentResponse {
pub id: i64,
pub identity_id: i64,
pub permission_set_id: i64,
pub permission_set_ref: String,
pub created: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PermissionSetRoleAssignmentResponse {
pub id: i64,
pub permission_set_id: i64,
pub permission_set_ref: Option<String>,
pub role: String,
pub created: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreatePermissionAssignmentRequest {
pub identity_id: Option<i64>,
pub identity_login: Option<String>,
pub permission_set_ref: String,
}
#[derive(Debug, Clone, Deserialize, Validate, ToSchema)]
pub struct CreateIdentityRoleAssignmentRequest {
#[validate(length(min = 1, max = 255))]
pub role: String,
}
#[derive(Debug, Clone, Deserialize, Validate, ToSchema)]
pub struct CreatePermissionSetRoleAssignmentRequest {
#[validate(length(min = 1, max = 255))]
pub role: String,
}
#[derive(Debug, Clone, Deserialize, Validate, ToSchema)]
pub struct CreateIdentityRequest {
#[validate(length(min = 3, max = 255))]
pub login: String,
#[validate(length(max = 255))]
pub display_name: Option<String>,
#[validate(length(min = 8, max = 128))]
pub password: Option<String>,
#[serde(default)]
pub attributes: JsonValue,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateIdentityRequest {
pub display_name: Option<String>,
pub password: Option<String>,
pub attributes: Option<JsonValue>,
pub frozen: Option<bool>,
}

View File

@@ -25,9 +25,8 @@ pub struct CreateRuleRequest {
pub label: String, pub label: String,
/// Rule description /// Rule description
#[validate(length(min = 1))]
#[schema(example = "Send Slack notification when an error occurs")] #[schema(example = "Send Slack notification when an error occurs")]
pub description: String, pub description: Option<String>,
/// Action reference to execute when rule matches /// Action reference to execute when rule matches
#[validate(length(min = 1, max = 255))] #[validate(length(min = 1, max = 255))]
@@ -69,7 +68,6 @@ pub struct UpdateRuleRequest {
pub label: Option<String>, pub label: Option<String>,
/// Rule description /// Rule description
#[validate(length(min = 1))]
#[schema(example = "Enhanced error notification with filtering")] #[schema(example = "Enhanced error notification with filtering")]
pub description: Option<String>, pub description: Option<String>,
@@ -115,7 +113,7 @@ pub struct RuleResponse {
/// Rule description /// Rule description
#[schema(example = "Send Slack notification when an error occurs")] #[schema(example = "Send Slack notification when an error occurs")]
pub description: String, pub description: Option<String>,
/// Action ID (null if the referenced action has been deleted) /// Action ID (null if the referenced action has been deleted)
#[schema(example = 1)] #[schema(example = 1)]
@@ -183,7 +181,7 @@ pub struct RuleSummary {
/// Rule description /// Rule description
#[schema(example = "Send Slack notification when an error occurs")] #[schema(example = "Send Slack notification when an error occurs")]
pub description: String, pub description: Option<String>,
/// Action reference /// Action reference
#[schema(example = "slack.post_message")] #[schema(example = "slack.post_message")]
@@ -297,7 +295,7 @@ mod tests {
r#ref: "".to_string(), // Invalid: empty r#ref: "".to_string(), // Invalid: empty
pack_ref: "test-pack".to_string(), pack_ref: "test-pack".to_string(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test description".to_string(), description: Some("Test description".to_string()),
action_ref: "test.action".to_string(), action_ref: "test.action".to_string(),
trigger_ref: "test.trigger".to_string(), trigger_ref: "test.trigger".to_string(),
conditions: default_empty_object(), conditions: default_empty_object(),
@@ -315,7 +313,7 @@ mod tests {
r#ref: "test.rule".to_string(), r#ref: "test.rule".to_string(),
pack_ref: "test-pack".to_string(), pack_ref: "test-pack".to_string(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test description".to_string(), description: Some("Test description".to_string()),
action_ref: "test.action".to_string(), action_ref: "test.action".to_string(),
trigger_ref: "test.trigger".to_string(), trigger_ref: "test.trigger".to_string(),
conditions: serde_json::json!({ conditions: serde_json::json!({

View File

@@ -0,0 +1,181 @@
//! Runtime DTOs for API requests and responses
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use utoipa::ToSchema;
use validator::Validate;
/// Request DTO for creating a runtime.
#[derive(Debug, Clone, Deserialize, Validate, ToSchema)]
pub struct CreateRuntimeRequest {
/// Unique reference identifier (e.g. "core.python", "core.nodejs")
#[validate(length(min = 1, max = 255))]
#[schema(example = "core.python")]
pub r#ref: String,
/// Optional pack reference this runtime belongs to
#[validate(length(min = 1, max = 255))]
#[schema(example = "core", nullable = true)]
pub pack_ref: Option<String>,
/// Optional human-readable description
#[validate(length(min = 1))]
#[schema(example = "Python runtime with virtualenv support", nullable = true)]
pub description: Option<String>,
/// Display name
#[validate(length(min = 1, max = 255))]
#[schema(example = "Python")]
pub name: String,
/// Distribution metadata used for verification and platform support
#[serde(default)]
#[schema(value_type = Object, example = json!({"linux": {"supported": true}}))]
pub distributions: JsonValue,
/// Optional installation metadata
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Object, nullable = true, example = json!({"method": "system"}))]
pub installation: Option<JsonValue>,
/// Runtime execution configuration
#[serde(default)]
#[schema(value_type = Object, example = json!({"interpreter": {"command": "python3"}}))]
pub execution_config: JsonValue,
}
/// Request DTO for updating a runtime.
#[derive(Debug, Clone, Deserialize, Validate, ToSchema)]
pub struct UpdateRuntimeRequest {
/// Optional human-readable description patch.
pub description: Option<NullableStringPatch>,
/// Display name
#[validate(length(min = 1, max = 255))]
#[schema(example = "Python 3")]
pub name: Option<String>,
/// Distribution metadata used for verification and platform support
#[schema(value_type = Object, nullable = true)]
pub distributions: Option<JsonValue>,
/// Optional installation metadata patch.
pub installation: Option<NullableJsonPatch>,
/// Runtime execution configuration
#[schema(value_type = Object, nullable = true)]
pub execution_config: Option<JsonValue>,
}
/// Explicit patch operation for nullable string fields.
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
#[serde(tag = "op", content = "value", rename_all = "snake_case")]
pub enum NullableStringPatch {
#[schema(title = "SetString")]
Set(String),
Clear,
}
/// Explicit patch operation for nullable JSON fields.
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
#[serde(tag = "op", content = "value", rename_all = "snake_case")]
pub enum NullableJsonPatch {
#[schema(title = "SetJson")]
Set(JsonValue),
Clear,
}
/// Full runtime response.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct RuntimeResponse {
#[schema(example = 1)]
pub id: i64,
#[schema(example = "core.python")]
pub r#ref: String,
#[schema(example = 1, nullable = true)]
pub pack: Option<i64>,
#[schema(example = "core", nullable = true)]
pub pack_ref: Option<String>,
#[schema(example = "Python runtime with virtualenv support", nullable = true)]
pub description: Option<String>,
#[schema(example = "Python")]
pub name: String,
#[schema(value_type = Object)]
pub distributions: JsonValue,
#[schema(value_type = Object, nullable = true)]
pub installation: Option<JsonValue>,
#[schema(value_type = Object)]
pub execution_config: JsonValue,
#[schema(example = "2024-01-13T10:30:00Z")]
pub created: DateTime<Utc>,
#[schema(example = "2024-01-13T10:30:00Z")]
pub updated: DateTime<Utc>,
}
/// Runtime summary for list views.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct RuntimeSummary {
#[schema(example = 1)]
pub id: i64,
#[schema(example = "core.python")]
pub r#ref: String,
#[schema(example = "core", nullable = true)]
pub pack_ref: Option<String>,
#[schema(example = "Python runtime with virtualenv support", nullable = true)]
pub description: Option<String>,
#[schema(example = "Python")]
pub name: String,
#[schema(example = "2024-01-13T10:30:00Z")]
pub created: DateTime<Utc>,
#[schema(example = "2024-01-13T10:30:00Z")]
pub updated: DateTime<Utc>,
}
impl From<attune_common::models::runtime::Runtime> for RuntimeResponse {
fn from(runtime: attune_common::models::runtime::Runtime) -> Self {
Self {
id: runtime.id,
r#ref: runtime.r#ref,
pack: runtime.pack,
pack_ref: runtime.pack_ref,
description: runtime.description,
name: runtime.name,
distributions: runtime.distributions,
installation: runtime.installation,
execution_config: runtime.execution_config,
created: runtime.created,
updated: runtime.updated,
}
}
}
impl From<attune_common::models::runtime::Runtime> for RuntimeSummary {
fn from(runtime: attune_common::models::runtime::Runtime) -> Self {
Self {
id: runtime.id,
r#ref: runtime.r#ref,
pack_ref: runtime.pack_ref,
description: runtime.description,
name: runtime.name,
created: runtime.created,
updated: runtime.updated,
}
}
}

View File

@@ -54,21 +54,35 @@ pub struct UpdateTriggerRequest {
/// Trigger description /// Trigger description
#[schema(example = "Updated webhook trigger description")] #[schema(example = "Updated webhook trigger description")]
pub description: Option<String>, pub description: Option<TriggerStringPatch>,
/// Parameter schema (StackStorm-style with inline required/secret) /// Parameter schema (StackStorm-style with inline required/secret)
#[schema(value_type = Object, nullable = true)] #[schema(value_type = Object, nullable = true)]
pub param_schema: Option<JsonValue>, pub param_schema: Option<TriggerJsonPatch>,
/// Output schema /// Output schema
#[schema(value_type = Object, nullable = true)] #[schema(value_type = Object, nullable = true)]
pub out_schema: Option<JsonValue>, pub out_schema: Option<TriggerJsonPatch>,
/// Whether the trigger is enabled /// Whether the trigger is enabled
#[schema(example = true)] #[schema(example = true)]
pub enabled: Option<bool>, pub enabled: Option<bool>,
} }
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
#[serde(tag = "op", content = "value", rename_all = "snake_case")]
pub enum TriggerStringPatch {
Set(String),
Clear,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
#[serde(tag = "op", content = "value", rename_all = "snake_case")]
pub enum TriggerJsonPatch {
Set(JsonValue),
Clear,
}
/// Response DTO for trigger information /// Response DTO for trigger information
#[derive(Debug, Clone, Serialize, ToSchema)] #[derive(Debug, Clone, Serialize, ToSchema)]
pub struct TriggerResponse { pub struct TriggerResponse {
@@ -189,9 +203,8 @@ pub struct CreateSensorRequest {
pub label: String, pub label: String,
/// Sensor description /// Sensor description
#[validate(length(min = 1))]
#[schema(example = "Monitors CPU usage and generates events")] #[schema(example = "Monitors CPU usage and generates events")]
pub description: String, pub description: Option<String>,
/// Entry point for sensor execution (e.g., path to script, function name) /// Entry point for sensor execution (e.g., path to script, function name)
#[validate(length(min = 1, max = 1024))] #[validate(length(min = 1, max = 1024))]
@@ -233,7 +246,6 @@ pub struct UpdateSensorRequest {
pub label: Option<String>, pub label: Option<String>,
/// Sensor description /// Sensor description
#[validate(length(min = 1))]
#[schema(example = "Enhanced CPU monitoring with alerts")] #[schema(example = "Enhanced CPU monitoring with alerts")]
pub description: Option<String>, pub description: Option<String>,
@@ -244,13 +256,20 @@ pub struct UpdateSensorRequest {
/// Parameter schema (StackStorm-style with inline required/secret) /// Parameter schema (StackStorm-style with inline required/secret)
#[schema(value_type = Object, nullable = true)] #[schema(value_type = Object, nullable = true)]
pub param_schema: Option<JsonValue>, pub param_schema: Option<SensorJsonPatch>,
/// Whether the sensor is enabled /// Whether the sensor is enabled
#[schema(example = false)] #[schema(example = false)]
pub enabled: Option<bool>, pub enabled: Option<bool>,
} }
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
#[serde(tag = "op", content = "value", rename_all = "snake_case")]
pub enum SensorJsonPatch {
Set(JsonValue),
Clear,
}
/// Response DTO for sensor information /// Response DTO for sensor information
#[derive(Debug, Clone, Serialize, ToSchema)] #[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SensorResponse { pub struct SensorResponse {
@@ -276,7 +295,7 @@ pub struct SensorResponse {
/// Sensor description /// Sensor description
#[schema(example = "Monitors CPU usage and generates events")] #[schema(example = "Monitors CPU usage and generates events")]
pub description: String, pub description: Option<String>,
/// Entry point /// Entry point
#[schema(example = "/sensors/monitoring/cpu_monitor.py")] #[schema(example = "/sensors/monitoring/cpu_monitor.py")]
@@ -336,7 +355,7 @@ pub struct SensorSummary {
/// Sensor description /// Sensor description
#[schema(example = "Monitors CPU usage and generates events")] #[schema(example = "Monitors CPU usage and generates events")]
pub description: String, pub description: Option<String>,
/// Trigger reference /// Trigger reference
#[schema(example = "monitoring.cpu_threshold")] #[schema(example = "monitoring.cpu_threshold")]
@@ -478,7 +497,7 @@ mod tests {
r#ref: "test.sensor".to_string(), r#ref: "test.sensor".to_string(),
pack_ref: "test-pack".to_string(), pack_ref: "test-pack".to_string(),
label: "Test Sensor".to_string(), label: "Test Sensor".to_string(),
description: "Test description".to_string(), description: Some("Test description".to_string()),
entrypoint: "/sensors/test.py".to_string(), entrypoint: "/sensors/test.py".to_string(),
runtime_ref: "python3".to_string(), runtime_ref: "python3".to_string(),
trigger_ref: "test.trigger".to_string(), trigger_ref: "test.trigger".to_string(),

View File

@@ -48,10 +48,6 @@ pub struct SaveWorkflowFileRequest {
/// Tags for categorization /// Tags for categorization
#[schema(example = json!(["deployment", "automation"]))] #[schema(example = json!(["deployment", "automation"]))]
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
/// Whether the workflow is enabled
#[schema(example = true)]
pub enabled: Option<bool>,
} }
/// Request DTO for creating a new workflow /// Request DTO for creating a new workflow
@@ -96,10 +92,6 @@ pub struct CreateWorkflowRequest {
/// Tags for categorization and search /// Tags for categorization and search
#[schema(example = json!(["incident", "slack", "approval"]))] #[schema(example = json!(["incident", "slack", "approval"]))]
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
/// Whether the workflow is enabled
#[schema(example = true)]
pub enabled: Option<bool>,
} }
/// Request DTO for updating a workflow /// Request DTO for updating a workflow
@@ -134,10 +126,6 @@ pub struct UpdateWorkflowRequest {
/// Tags /// Tags
#[schema(example = json!(["incident", "slack", "approval", "automation"]))] #[schema(example = json!(["incident", "slack", "approval", "automation"]))]
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
/// Whether the workflow is enabled
#[schema(example = true)]
pub enabled: Option<bool>,
} }
/// Response DTO for workflow information /// Response DTO for workflow information
@@ -187,10 +175,6 @@ pub struct WorkflowResponse {
#[schema(example = json!(["incident", "slack", "approval"]))] #[schema(example = json!(["incident", "slack", "approval"]))]
pub tags: Vec<String>, pub tags: Vec<String>,
/// Whether the workflow is enabled
#[schema(example = true)]
pub enabled: bool,
/// Creation timestamp /// Creation timestamp
#[schema(example = "2024-01-13T10:30:00Z")] #[schema(example = "2024-01-13T10:30:00Z")]
pub created: DateTime<Utc>, pub created: DateTime<Utc>,
@@ -231,10 +215,6 @@ pub struct WorkflowSummary {
#[schema(example = json!(["incident", "slack", "approval"]))] #[schema(example = json!(["incident", "slack", "approval"]))]
pub tags: Vec<String>, pub tags: Vec<String>,
/// Whether the workflow is enabled
#[schema(example = true)]
pub enabled: bool,
/// Creation timestamp /// Creation timestamp
#[schema(example = "2024-01-13T10:30:00Z")] #[schema(example = "2024-01-13T10:30:00Z")]
pub created: DateTime<Utc>, pub created: DateTime<Utc>,
@@ -259,7 +239,6 @@ impl From<attune_common::models::workflow::WorkflowDefinition> for WorkflowRespo
out_schema: workflow.out_schema, out_schema: workflow.out_schema,
definition: workflow.definition, definition: workflow.definition,
tags: workflow.tags, tags: workflow.tags,
enabled: workflow.enabled,
created: workflow.created, created: workflow.created,
updated: workflow.updated, updated: workflow.updated,
} }
@@ -277,7 +256,6 @@ impl From<attune_common::models::workflow::WorkflowDefinition> for WorkflowSumma
description: workflow.description, description: workflow.description,
version: workflow.version, version: workflow.version,
tags: workflow.tags, tags: workflow.tags,
enabled: workflow.enabled,
created: workflow.created, created: workflow.created,
updated: workflow.updated, updated: workflow.updated,
} }
@@ -291,10 +269,6 @@ pub struct WorkflowSearchParams {
#[param(example = "incident,approval")] #[param(example = "incident,approval")]
pub tags: Option<String>, pub tags: Option<String>,
/// Filter by enabled status
#[param(example = true)]
pub enabled: Option<bool>,
/// Search term for label/description (case-insensitive) /// Search term for label/description (case-insensitive)
#[param(example = "incident")] #[param(example = "incident")]
pub search: Option<String>, pub search: Option<String>,
@@ -320,7 +294,6 @@ mod tests {
out_schema: None, out_schema: None,
definition: serde_json::json!({"tasks": []}), definition: serde_json::json!({"tasks": []}),
tags: None, tags: None,
enabled: None,
}; };
assert!(req.validate().is_err()); assert!(req.validate().is_err());
@@ -338,7 +311,6 @@ mod tests {
out_schema: None, out_schema: None,
definition: serde_json::json!({"tasks": []}), definition: serde_json::json!({"tasks": []}),
tags: Some(vec!["test".to_string()]), tags: Some(vec!["test".to_string()]),
enabled: Some(true),
}; };
assert!(req.validate().is_ok()); assert!(req.validate().is_ok());
@@ -354,7 +326,6 @@ mod tests {
out_schema: None, out_schema: None,
definition: None, definition: None,
tags: None, tags: None,
enabled: None,
}; };
// Should be valid even with all None values // Should be valid even with all None values
@@ -365,7 +336,6 @@ mod tests {
fn test_workflow_search_params() { fn test_workflow_search_params() {
let params = WorkflowSearchParams { let params = WorkflowSearchParams {
tags: Some("incident,approval".to_string()), tags: Some("incident,approval".to_string()),
enabled: Some(true),
search: Some("response".to_string()), search: Some("response".to_string()),
pack_ref: Some("core".to_string()), pack_ref: Some("core".to_string()),
}; };

View File

@@ -5,6 +5,7 @@
//! It is primarily used by the binary target and integration tests. //! It is primarily used by the binary target and integration tests.
pub mod auth; pub mod auth;
pub mod authz;
pub mod dto; pub mod dto;
pub mod middleware; pub mod middleware;
pub mod openapi; pub mod openapi;

View File

@@ -115,6 +115,10 @@ async fn mq_reconnect_loop(state: Arc<AppState>, mq_url: String) {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Install a JWT crypto provider that supports both Attune's HS tokens
// and external RS256 OIDC identity tokens.
let _ = jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER.install_default();
// Initialize tracing subscriber // Initialize tracing subscriber
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_target(false) .with_target(false)

View File

@@ -10,8 +10,8 @@ use crate::dto::{
ActionResponse, ActionSummary, CreateActionRequest, QueueStatsResponse, UpdateActionRequest, ActionResponse, ActionSummary, CreateActionRequest, QueueStatsResponse, UpdateActionRequest,
}, },
auth::{ auth::{
ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest, AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest,
RegisterRequest, TokenResponse, RefreshTokenRequest, RegisterRequest, TokenResponse,
}, },
common::{ApiResponse, PaginatedResponse, PaginationMeta, SuccessResponse}, common::{ApiResponse, PaginatedResponse, PaginationMeta, SuccessResponse},
event::{EnforcementResponse, EnforcementSummary, EventResponse, EventSummary}, event::{EnforcementResponse, EnforcementSummary, EventResponse, EventSummary},
@@ -26,7 +26,15 @@ use crate::dto::{
PackWorkflowSyncResponse, PackWorkflowValidationResponse, RegisterPackRequest, PackWorkflowSyncResponse, PackWorkflowValidationResponse, RegisterPackRequest,
UpdatePackRequest, WorkflowSyncResult, UpdatePackRequest, WorkflowSyncResult,
}, },
permission::{
CreateIdentityRequest, CreateIdentityRoleAssignmentRequest,
CreatePermissionAssignmentRequest, CreatePermissionSetRoleAssignmentRequest,
IdentityResponse, IdentityRoleAssignmentResponse, IdentitySummary,
PermissionAssignmentResponse, PermissionSetRoleAssignmentResponse, PermissionSetSummary,
UpdateIdentityRequest,
},
rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest}, rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest},
runtime::{CreateRuntimeRequest, RuntimeResponse, RuntimeSummary, UpdateRuntimeRequest},
trigger::{ trigger::{
CreateSensorRequest, CreateTriggerRequest, SensorResponse, SensorSummary, TriggerResponse, CreateSensorRequest, CreateTriggerRequest, SensorResponse, SensorSummary, TriggerResponse,
TriggerSummary, UpdateSensorRequest, UpdateTriggerRequest, TriggerSummary, UpdateSensorRequest, UpdateTriggerRequest,
@@ -63,7 +71,9 @@ use crate::dto::{
crate::routes::health::liveness, crate::routes::health::liveness,
// Authentication // Authentication
crate::routes::auth::auth_settings,
crate::routes::auth::login, crate::routes::auth::login,
crate::routes::auth::ldap_login,
crate::routes::auth::register, crate::routes::auth::register,
crate::routes::auth::refresh_token, crate::routes::auth::refresh_token,
crate::routes::auth::get_current_user, crate::routes::auth::get_current_user,
@@ -92,6 +102,14 @@ use crate::dto::{
crate::routes::actions::delete_action, crate::routes::actions::delete_action,
crate::routes::actions::get_queue_stats, crate::routes::actions::get_queue_stats,
// Runtimes
crate::routes::runtimes::list_runtimes,
crate::routes::runtimes::list_runtimes_by_pack,
crate::routes::runtimes::get_runtime,
crate::routes::runtimes::create_runtime,
crate::routes::runtimes::update_runtime,
crate::routes::runtimes::delete_runtime,
// Triggers // Triggers
crate::routes::triggers::list_triggers, crate::routes::triggers::list_triggers,
crate::routes::triggers::list_enabled_triggers, crate::routes::triggers::list_enabled_triggers,
@@ -160,6 +178,23 @@ use crate::dto::{
crate::routes::keys::update_key, crate::routes::keys::update_key,
crate::routes::keys::delete_key, crate::routes::keys::delete_key,
// Permissions
crate::routes::permissions::list_identities,
crate::routes::permissions::get_identity,
crate::routes::permissions::create_identity,
crate::routes::permissions::update_identity,
crate::routes::permissions::delete_identity,
crate::routes::permissions::list_permission_sets,
crate::routes::permissions::list_identity_permissions,
crate::routes::permissions::create_permission_assignment,
crate::routes::permissions::delete_permission_assignment,
crate::routes::permissions::create_identity_role_assignment,
crate::routes::permissions::delete_identity_role_assignment,
crate::routes::permissions::create_permission_set_role_assignment,
crate::routes::permissions::delete_permission_set_role_assignment,
crate::routes::permissions::freeze_identity,
crate::routes::permissions::unfreeze_identity,
// Workflows // Workflows
crate::routes::workflows::list_workflows, crate::routes::workflows::list_workflows,
crate::routes::workflows::list_workflows_by_pack, crate::routes::workflows::list_workflows_by_pack,
@@ -173,15 +208,21 @@ use crate::dto::{
crate::routes::webhooks::disable_webhook, crate::routes::webhooks::disable_webhook,
crate::routes::webhooks::regenerate_webhook_key, crate::routes::webhooks::regenerate_webhook_key,
crate::routes::webhooks::receive_webhook, crate::routes::webhooks::receive_webhook,
// Agent
crate::routes::agent::download_agent_binary,
crate::routes::agent::agent_info,
), ),
components( components(
schemas( schemas(
// Common types // Common types
ApiResponse<TokenResponse>, ApiResponse<TokenResponse>,
ApiResponse<AuthSettingsResponse>,
ApiResponse<CurrentUserResponse>, ApiResponse<CurrentUserResponse>,
ApiResponse<PackResponse>, ApiResponse<PackResponse>,
ApiResponse<PackInstallResponse>, ApiResponse<PackInstallResponse>,
ApiResponse<ActionResponse>, ApiResponse<ActionResponse>,
ApiResponse<RuntimeResponse>,
ApiResponse<TriggerResponse>, ApiResponse<TriggerResponse>,
ApiResponse<SensorResponse>, ApiResponse<SensorResponse>,
ApiResponse<RuleResponse>, ApiResponse<RuleResponse>,
@@ -190,10 +231,13 @@ use crate::dto::{
ApiResponse<EnforcementResponse>, ApiResponse<EnforcementResponse>,
ApiResponse<InquiryResponse>, ApiResponse<InquiryResponse>,
ApiResponse<KeyResponse>, ApiResponse<KeyResponse>,
ApiResponse<IdentityResponse>,
ApiResponse<PermissionAssignmentResponse>,
ApiResponse<WorkflowResponse>, ApiResponse<WorkflowResponse>,
ApiResponse<QueueStatsResponse>, ApiResponse<QueueStatsResponse>,
PaginatedResponse<PackSummary>, PaginatedResponse<PackSummary>,
PaginatedResponse<ActionSummary>, PaginatedResponse<ActionSummary>,
PaginatedResponse<RuntimeSummary>,
PaginatedResponse<TriggerSummary>, PaginatedResponse<TriggerSummary>,
PaginatedResponse<SensorSummary>, PaginatedResponse<SensorSummary>,
PaginatedResponse<RuleSummary>, PaginatedResponse<RuleSummary>,
@@ -202,12 +246,14 @@ use crate::dto::{
PaginatedResponse<EnforcementSummary>, PaginatedResponse<EnforcementSummary>,
PaginatedResponse<InquirySummary>, PaginatedResponse<InquirySummary>,
PaginatedResponse<KeySummary>, PaginatedResponse<KeySummary>,
PaginatedResponse<IdentitySummary>,
PaginatedResponse<WorkflowSummary>, PaginatedResponse<WorkflowSummary>,
PaginationMeta, PaginationMeta,
SuccessResponse, SuccessResponse,
// Auth DTOs // Auth DTOs
LoginRequest, LoginRequest,
crate::routes::auth::LdapLoginRequest,
RegisterRequest, RegisterRequest,
RefreshTokenRequest, RefreshTokenRequest,
ChangePasswordRequest, ChangePasswordRequest,
@@ -233,6 +279,25 @@ use crate::dto::{
attune_common::models::pack_test::PackTestSummary, attune_common::models::pack_test::PackTestSummary,
PaginatedResponse<attune_common::models::pack_test::PackTestSummary>, PaginatedResponse<attune_common::models::pack_test::PackTestSummary>,
// Permission DTOs
CreateIdentityRequest,
UpdateIdentityRequest,
IdentityResponse,
PermissionSetSummary,
PermissionAssignmentResponse,
CreatePermissionAssignmentRequest,
CreateIdentityRoleAssignmentRequest,
IdentityRoleAssignmentResponse,
CreatePermissionSetRoleAssignmentRequest,
PermissionSetRoleAssignmentResponse,
// Runtime DTOs
CreateRuntimeRequest,
UpdateRuntimeRequest,
RuntimeResponse,
RuntimeSummary,
IdentitySummary,
// Action DTOs // Action DTOs
CreateActionRequest, CreateActionRequest,
UpdateActionRequest, UpdateActionRequest,
@@ -293,6 +358,10 @@ use crate::dto::{
WebhookReceiverRequest, WebhookReceiverRequest,
WebhookReceiverResponse, WebhookReceiverResponse,
ApiResponse<WebhookReceiverResponse>, ApiResponse<WebhookReceiverResponse>,
// Agent DTOs
crate::routes::agent::AgentBinaryInfo,
crate::routes::agent::AgentArchInfo,
) )
), ),
modifiers(&SecurityAddon), modifiers(&SecurityAddon),
@@ -311,6 +380,7 @@ use crate::dto::{
(name = "secrets", description = "Secret management endpoints"), (name = "secrets", description = "Secret management endpoints"),
(name = "workflows", description = "Workflow management endpoints"), (name = "workflows", description = "Workflow management endpoints"),
(name = "webhooks", description = "Webhook management and receiver endpoints"), (name = "webhooks", description = "Webhook management and receiver endpoints"),
(name = "agent", description = "Agent binary distribution endpoints"),
) )
)] )]
pub struct ApiDoc; pub struct ApiDoc;
@@ -393,18 +463,57 @@ mod tests {
// We have 57 unique paths with 81 total operations (HTTP methods) // We have 57 unique paths with 81 total operations (HTTP methods)
// This test ensures we don't accidentally remove endpoints // This test ensures we don't accidentally remove endpoints
assert!( assert!(
path_count >= 57, path_count >= 59,
"Expected at least 57 unique API paths, found {}", "Expected at least 59 unique API paths, found {}",
path_count path_count
); );
assert!( assert!(
operation_count >= 81, operation_count >= 83,
"Expected at least 81 API operations, found {}", "Expected at least 83 API operations, found {}",
operation_count operation_count
); );
println!("Total API paths: {}", path_count); println!("Total API paths: {}", path_count);
println!("Total API operations: {}", operation_count); println!("Total API operations: {}", operation_count);
} }
#[test]
fn test_auth_endpoints_registered() {
let doc = ApiDoc::openapi();
let expected_auth_paths = vec![
"/auth/settings",
"/auth/login",
"/auth/ldap/login",
"/auth/register",
"/auth/refresh",
"/auth/me",
"/auth/change-password",
];
for path in &expected_auth_paths {
assert!(
doc.paths.paths.contains_key(*path),
"Expected auth endpoint {} to be registered in OpenAPI spec, but it was missing. \
Registered paths: {:?}",
path,
doc.paths.paths.keys().collect::<Vec<_>>()
);
}
}
#[test]
fn test_ldap_login_request_schema_registered() {
let doc = ApiDoc::openapi();
let components = doc.components.as_ref().expect("components should exist");
assert!(
components.schemas.contains_key("LdapLoginRequest"),
"Expected LdapLoginRequest schema to be registered in OpenAPI components. \
Registered schemas: {:?}",
components.schemas.keys().collect::<Vec<_>>()
);
}
} }

View File

@@ -10,19 +10,21 @@ use axum::{
use std::sync::Arc; use std::sync::Arc;
use validator::Validate; use validator::Validate;
use attune_common::rbac::{Action, AuthorizationContext, Resource};
use attune_common::repositories::{ use attune_common::repositories::{
action::{ActionRepository, ActionSearchFilters, CreateActionInput, UpdateActionInput}, action::{ActionRepository, ActionSearchFilters, CreateActionInput, UpdateActionInput},
pack::PackRepository, pack::PackRepository,
queue_stats::QueueStatsRepository, queue_stats::QueueStatsRepository,
Create, Delete, FindByRef, Update, Create, Delete, FindByRef, Patch, Update,
}; };
use crate::{ use crate::{
auth::middleware::RequireAuth, auth::middleware::RequireAuth,
authz::{AuthorizationCheck, AuthorizationService},
dto::{ dto::{
action::{ action::{
ActionResponse, ActionSummary, CreateActionRequest, QueueStatsResponse, ActionResponse, ActionSummary, CreateActionRequest, QueueStatsResponse,
UpdateActionRequest, RuntimeVersionConstraintPatch, UpdateActionRequest,
}, },
common::{PaginatedResponse, PaginationParams}, common::{PaginatedResponse, PaginationParams},
ApiResponse, SuccessResponse, ApiResponse, SuccessResponse,
@@ -153,7 +155,7 @@ pub async fn get_action(
)] )]
pub async fn create_action( pub async fn create_action(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
Json(request): Json<CreateActionRequest>, Json(request): Json<CreateActionRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Validate request // Validate request
@@ -175,6 +177,26 @@ pub async fn create_action(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", request.pack_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", request.pack_ref)))?;
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.pack_ref = Some(pack.r#ref.clone());
ctx.target_ref = Some(request.r#ref.clone());
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Actions,
action: Action::Create,
context: ctx,
},
)
.await?;
}
// If runtime is specified, we could verify it exists (future enhancement) // If runtime is specified, we could verify it exists (future enhancement)
// For now, the database foreign key constraint will handle invalid runtime IDs // For now, the database foreign key constraint will handle invalid runtime IDs
@@ -219,7 +241,7 @@ pub async fn create_action(
)] )]
pub async fn update_action( pub async fn update_action(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
Path(action_ref): Path<String>, Path(action_ref): Path<String>,
Json(request): Json<UpdateActionRequest>, Json(request): Json<UpdateActionRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -231,13 +253,37 @@ pub async fn update_action(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Action '{}' not found", action_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Action '{}' not found", action_ref)))?;
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_id = Some(existing_action.id);
ctx.target_ref = Some(existing_action.r#ref.clone());
ctx.pack_ref = Some(existing_action.pack_ref.clone());
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Actions,
action: Action::Update,
context: ctx,
},
)
.await?;
}
// Create update input // Create update input
let update_input = UpdateActionInput { let update_input = UpdateActionInput {
label: request.label, label: request.label,
description: request.description, description: request.description.map(Patch::Set),
entrypoint: request.entrypoint, entrypoint: request.entrypoint,
runtime: request.runtime, runtime: request.runtime,
runtime_version_constraint: request.runtime_version_constraint, runtime_version_constraint: request.runtime_version_constraint.map(|patch| match patch {
RuntimeVersionConstraintPatch::Set(value) => Patch::Set(value),
RuntimeVersionConstraintPatch::Clear => Patch::Clear,
}),
param_schema: request.param_schema, param_schema: request.param_schema,
out_schema: request.out_schema, out_schema: request.out_schema,
parameter_delivery: None, parameter_delivery: None,
@@ -269,7 +315,7 @@ pub async fn update_action(
)] )]
pub async fn delete_action( pub async fn delete_action(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
Path(action_ref): Path<String>, Path(action_ref): Path<String>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Check if action exists // Check if action exists
@@ -277,6 +323,27 @@ pub async fn delete_action(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Action '{}' not found", action_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Action '{}' not found", action_ref)))?;
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_id = Some(action.id);
ctx.target_ref = Some(action.r#ref.clone());
ctx.pack_ref = Some(action.pack_ref.clone());
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Actions,
action: Action::Delete,
context: ctx,
},
)
.await?;
}
// Delete the action // Delete the action
let deleted = ActionRepository::delete(&state.db, action.id).await?; let deleted = ActionRepository::delete(&state.db, action.id).await?;

View File

@@ -0,0 +1,482 @@
//! Agent binary download endpoints
//!
//! Provides endpoints for downloading the attune-agent binary for injection
//! into arbitrary containers. This supports deployments where shared Docker
//! volumes are impractical (Kubernetes, ECS, remote Docker hosts).
use axum::{
body::Body,
extract::{Query, State},
http::{header, HeaderMap, StatusCode},
response::IntoResponse,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use subtle::ConstantTimeEq;
use tokio::fs;
use tokio_util::io::ReaderStream;
use utoipa::{IntoParams, ToSchema};
use crate::state::AppState;
/// Query parameters for the binary download endpoint
#[derive(Debug, Deserialize, IntoParams)]
pub struct BinaryDownloadParams {
/// Target architecture (x86_64, aarch64). Defaults to x86_64.
#[param(example = "x86_64")]
pub arch: Option<String>,
/// Optional bootstrap token for authentication
pub token: Option<String>,
}
/// Agent binary metadata
#[derive(Debug, Serialize, ToSchema)]
pub struct AgentBinaryInfo {
/// Available architectures
pub architectures: Vec<AgentArchInfo>,
/// Agent version (from build)
pub version: String,
}
/// Per-architecture binary info
#[derive(Debug, Serialize, ToSchema)]
pub struct AgentArchInfo {
/// Architecture name
pub arch: String,
/// Binary size in bytes
pub size_bytes: u64,
/// Whether this binary is available
pub available: bool,
}
/// Validate that the architecture name is safe (no path traversal) and normalize it.
fn validate_arch(arch: &str) -> Result<&str, (StatusCode, Json<serde_json::Value>)> {
match arch {
"x86_64" | "aarch64" => Ok(arch),
// Accept arm64 as an alias for aarch64
"arm64" => Ok("aarch64"),
_ => Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid architecture",
"message": format!("Unsupported architecture '{}'. Supported: x86_64, aarch64", arch),
})),
)),
}
}
/// Validate bootstrap token if configured.
///
/// If the agent config has a `bootstrap_token` set, the request must provide it
/// via the `X-Agent-Token` header or the `token` query parameter. If no token
/// is configured, access is unrestricted.
fn validate_token(
config: &attune_common::config::Config,
headers: &HeaderMap,
query_token: &Option<String>,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
let expected_token = config
.agent
.as_ref()
.and_then(|ac| ac.bootstrap_token.as_ref());
let expected_token = match expected_token {
Some(t) => t,
None => {
use std::sync::Once;
static WARN_ONCE: Once = Once::new();
WARN_ONCE.call_once(|| {
tracing::warn!(
"Agent binary download endpoint has no bootstrap_token configured. \
Anyone with network access to the API can download the agent binary. \
Set agent.bootstrap_token in config to restrict access."
);
});
return Ok(());
}
};
// Check X-Agent-Token header first, then query param
let provided_token = headers
.get("x-agent-token")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.or_else(|| query_token.clone());
match provided_token {
Some(ref t) if bool::from(t.as_bytes().ct_eq(expected_token.as_bytes())) => Ok(()),
Some(_) => Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Invalid token",
"message": "The provided bootstrap token is invalid",
})),
)),
None => Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Token required",
"message": "A bootstrap token is required. Provide via X-Agent-Token header or token query parameter.",
})),
)),
}
}
/// Download the agent binary
///
/// Returns the statically-linked attune-agent binary for the requested architecture.
/// The binary can be injected into any container to turn it into an Attune worker.
#[utoipa::path(
get,
path = "/api/v1/agent/binary",
params(BinaryDownloadParams),
responses(
(status = 200, description = "Agent binary", content_type = "application/octet-stream"),
(status = 400, description = "Invalid architecture"),
(status = 401, description = "Invalid or missing bootstrap token"),
(status = 404, description = "Agent binary not found"),
(status = 503, description = "Agent binary distribution not configured"),
),
tag = "agent"
)]
pub async fn download_agent_binary(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(params): Query<BinaryDownloadParams>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
// Validate bootstrap token if configured
validate_token(&state.config, &headers, &params.token)?;
let agent_config = state.config.agent.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "Not configured",
"message": "Agent binary distribution is not configured. Set agent.binary_dir in config.",
})),
)
})?;
let arch = params.arch.as_deref().unwrap_or("x86_64");
let arch = validate_arch(arch)?;
let binary_dir = std::path::Path::new(&agent_config.binary_dir);
// Try arch-specific binary first, then fall back to generic name.
// IMPORTANT: The generic `attune-agent` binary is only safe to serve for
// x86_64 requests, because the current build pipeline produces an
// x86_64-unknown-linux-musl binary. Serving it for aarch64/arm64 would
// give the caller an incompatible executable (exec format error).
let arch_specific = binary_dir.join(format!("attune-agent-{}", arch));
let generic = binary_dir.join("attune-agent");
let binary_path = if arch_specific.exists() {
arch_specific
} else if arch == "x86_64" && generic.exists() {
tracing::debug!(
"Arch-specific binary not found at {:?}, falling back to generic {:?} (safe for x86_64)",
arch_specific,
generic
);
generic
} else {
tracing::warn!(
"Agent binary not found. Checked: {:?} and {:?}",
arch_specific,
generic
);
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": format!(
"Agent binary not found for architecture '{}'. Ensure the agent binary is built and placed in '{}'.",
arch,
agent_config.binary_dir
),
})),
));
};
// Get file metadata for Content-Length
let metadata = fs::metadata(&binary_path).await.map_err(|e| {
tracing::error!(
"Failed to read agent binary metadata at {:?}: {}",
binary_path,
e
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal error",
"message": "Failed to read agent binary",
})),
)
})?;
// Open file for streaming
let file = fs::File::open(&binary_path).await.map_err(|e| {
tracing::error!("Failed to open agent binary at {:?}: {}", binary_path, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal error",
"message": "Failed to open agent binary",
})),
)
})?;
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
let headers_response = [
(header::CONTENT_TYPE, "application/octet-stream".to_string()),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\"attune-agent\"".to_string(),
),
(header::CONTENT_LENGTH, metadata.len().to_string()),
(header::CACHE_CONTROL, "public, max-age=3600".to_string()),
];
tracing::info!(
arch = arch,
size_bytes = metadata.len(),
path = ?binary_path,
"Serving agent binary download"
);
Ok((headers_response, body))
}
/// Get agent binary metadata
///
/// Returns information about available agent binaries, including
/// supported architectures and binary sizes.
#[utoipa::path(
get,
path = "/api/v1/agent/info",
responses(
(status = 200, description = "Agent binary info", body = AgentBinaryInfo),
(status = 503, description = "Agent binary distribution not configured"),
),
tag = "agent"
)]
pub async fn agent_info(
State(state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let agent_config = state.config.agent.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "Not configured",
"message": "Agent binary distribution is not configured.",
})),
)
})?;
let binary_dir = std::path::Path::new(&agent_config.binary_dir);
let architectures = ["x86_64", "aarch64"];
let mut arch_infos = Vec::new();
for arch in &architectures {
let arch_specific = binary_dir.join(format!("attune-agent-{}", arch));
let generic = binary_dir.join("attune-agent");
// Only fall back to the generic binary for x86_64, since the build
// pipeline currently produces x86_64-only generic binaries.
let (available, size_bytes) = if arch_specific.exists() {
match fs::metadata(&arch_specific).await {
Ok(m) => (true, m.len()),
Err(_) => (false, 0),
}
} else if *arch == "x86_64" && generic.exists() {
match fs::metadata(&generic).await {
Ok(m) => (true, m.len()),
Err(_) => (false, 0),
}
} else {
(false, 0)
};
arch_infos.push(AgentArchInfo {
arch: arch.to_string(),
size_bytes,
available,
});
}
Ok(Json(AgentBinaryInfo {
architectures: arch_infos,
version: env!("CARGO_PKG_VERSION").to_string(),
}))
}
/// Create agent routes
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/agent/binary", get(download_agent_binary))
.route("/agent/info", get(agent_info))
}
#[cfg(test)]
mod tests {
use super::*;
use attune_common::config::AgentConfig;
use axum::http::{HeaderMap, HeaderValue};
// ── validate_arch tests ─────────────────────────────────────────
#[test]
fn test_validate_arch_valid_x86_64() {
let result = validate_arch("x86_64");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "x86_64");
}
#[test]
fn test_validate_arch_valid_aarch64() {
let result = validate_arch("aarch64");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "aarch64");
}
#[test]
fn test_validate_arch_arm64_alias() {
// "arm64" is an alias for "aarch64"
let result = validate_arch("arm64");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "aarch64");
}
#[test]
fn test_validate_arch_invalid() {
let result = validate_arch("mips");
assert!(result.is_err());
let (status, body) = result.unwrap_err();
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body.0["error"], "Invalid architecture");
}
// ── validate_token tests ────────────────────────────────────────
/// Helper: build a minimal Config with the given agent config.
/// Only the `agent` field is relevant for `validate_token`.
fn test_config(agent: Option<AgentConfig>) -> attune_common::config::Config {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
let config_path = format!("{}/../../config.test.yaml", manifest_dir);
let mut config = attune_common::config::Config::load_from_file(&config_path)
.expect("Failed to load test config");
config.agent = agent;
config
}
#[test]
fn test_validate_token_no_config() {
// When no agent config is set at all, no token is required.
let config = test_config(None);
let headers = HeaderMap::new();
let query_token = None;
let result = validate_token(&config, &headers, &query_token);
assert!(result.is_ok());
}
#[test]
fn test_validate_token_no_bootstrap_token_configured() {
// Agent config exists but bootstrap_token is None → no token required.
let config = test_config(Some(AgentConfig {
binary_dir: "/tmp/test".to_string(),
bootstrap_token: None,
}));
let headers = HeaderMap::new();
let query_token = None;
let result = validate_token(&config, &headers, &query_token);
assert!(result.is_ok());
}
#[test]
fn test_validate_token_valid_from_header() {
let config = test_config(Some(AgentConfig {
binary_dir: "/tmp/test".to_string(),
bootstrap_token: Some("s3cret-bootstrap".to_string()),
}));
let mut headers = HeaderMap::new();
headers.insert(
"x-agent-token",
HeaderValue::from_static("s3cret-bootstrap"),
);
let query_token = None;
let result = validate_token(&config, &headers, &query_token);
assert!(result.is_ok());
}
#[test]
fn test_validate_token_valid_from_query() {
let config = test_config(Some(AgentConfig {
binary_dir: "/tmp/test".to_string(),
bootstrap_token: Some("s3cret-bootstrap".to_string()),
}));
let headers = HeaderMap::new();
let query_token = Some("s3cret-bootstrap".to_string());
let result = validate_token(&config, &headers, &query_token);
assert!(result.is_ok());
}
#[test]
fn test_validate_token_invalid() {
let config = test_config(Some(AgentConfig {
binary_dir: "/tmp/test".to_string(),
bootstrap_token: Some("correct-token".to_string()),
}));
let mut headers = HeaderMap::new();
headers.insert("x-agent-token", HeaderValue::from_static("wrong-token"));
let query_token = None;
let result = validate_token(&config, &headers, &query_token);
assert!(result.is_err());
let (status, body) = result.unwrap_err();
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body.0["error"], "Invalid token");
}
#[test]
fn test_validate_token_missing_when_required() {
// bootstrap_token is configured but caller provides nothing.
let config = test_config(Some(AgentConfig {
binary_dir: "/tmp/test".to_string(),
bootstrap_token: Some("required-token".to_string()),
}));
let headers = HeaderMap::new();
let query_token = None;
let result = validate_token(&config, &headers, &query_token);
assert!(result.is_err());
let (status, body) = result.unwrap_err();
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body.0["error"], "Token required");
}
#[test]
fn test_validate_token_header_takes_precedence_over_query() {
// When both header and query provide a token, the header value is
// checked first (it appears first in the or_else chain). Provide a
// valid token in the header and an invalid one in the query — should
// succeed because the header matches.
let config = test_config(Some(AgentConfig {
binary_dir: "/tmp/test".to_string(),
bootstrap_token: Some("the-real-token".to_string()),
}));
let mut headers = HeaderMap::new();
headers.insert("x-agent-token", HeaderValue::from_static("the-real-token"));
let query_token = Some("wrong-token".to_string());
let result = validate_token(&config, &headers, &query_token);
assert!(result.is_ok());
}
}

View File

@@ -36,15 +36,17 @@ use attune_common::repositories::{
ArtifactRepository, ArtifactSearchFilters, ArtifactVersionRepository, CreateArtifactInput, ArtifactRepository, ArtifactSearchFilters, ArtifactVersionRepository, CreateArtifactInput,
CreateArtifactVersionInput, UpdateArtifactInput, CreateArtifactVersionInput, UpdateArtifactInput,
}, },
Create, Delete, FindById, FindByRef, Update, Create, Delete, FindById, FindByRef, Patch, Update,
}; };
use crate::{ use crate::{
auth::middleware::RequireAuth, auth::{jwt::TokenType, middleware::AuthenticatedUser, middleware::RequireAuth},
authz::{AuthorizationCheck, AuthorizationService},
dto::{ dto::{
artifact::{ artifact::{
AllocateFileVersionByRefRequest, AppendProgressRequest, ArtifactQueryParams, AllocateFileVersionByRefRequest, AppendProgressRequest, ArtifactExecutionPatch,
ArtifactResponse, ArtifactSummary, ArtifactVersionResponse, ArtifactVersionSummary, ArtifactJsonPatch, ArtifactQueryParams, ArtifactResponse, ArtifactStringPatch,
ArtifactSummary, ArtifactVersionResponse, ArtifactVersionSummary,
CreateArtifactRequest, CreateFileVersionRequest, CreateVersionJsonRequest, CreateArtifactRequest, CreateFileVersionRequest, CreateVersionJsonRequest,
SetDataRequest, UpdateArtifactRequest, SetDataRequest, UpdateArtifactRequest,
}, },
@@ -54,6 +56,7 @@ use crate::{
middleware::{ApiError, ApiResult}, middleware::{ApiError, ApiResult},
state::AppState, state::AppState,
}; };
use attune_common::rbac::{Action, AuthorizationContext, Resource};
// ============================================================================ // ============================================================================
// Artifact CRUD // Artifact CRUD
@@ -71,7 +74,7 @@ use crate::{
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn list_artifacts( pub async fn list_artifacts(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Query(query): Query<ArtifactQueryParams>, Query(query): Query<ArtifactQueryParams>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -87,8 +90,16 @@ pub async fn list_artifacts(
}; };
let result = ArtifactRepository::search(&state.db, &filters).await?; let result = ArtifactRepository::search(&state.db, &filters).await?;
let mut rows = result.rows;
let items: Vec<ArtifactSummary> = result.rows.into_iter().map(ArtifactSummary::from).collect(); if let Some((identity_id, grants)) = ensure_can_read_any_artifact(&state, &user).await? {
rows.retain(|artifact| {
let ctx = artifact_authorization_context(identity_id, artifact);
AuthorizationService::is_allowed(&grants, Resource::Artifacts, Action::Read, &ctx)
});
}
let items: Vec<ArtifactSummary> = rows.into_iter().map(ArtifactSummary::from).collect();
let pagination = PaginationParams { let pagination = PaginationParams {
page: query.page, page: query.page,
@@ -112,7 +123,7 @@ pub async fn list_artifacts(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_artifact( pub async fn get_artifact(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -120,6 +131,10 @@ pub async fn get_artifact(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Read, &artifact)
.await
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
Json(ApiResponse::new(ArtifactResponse::from(artifact))), Json(ApiResponse::new(ArtifactResponse::from(artifact))),
@@ -139,7 +154,7 @@ pub async fn get_artifact(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_artifact_by_ref( pub async fn get_artifact_by_ref(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(artifact_ref): Path<String>, Path(artifact_ref): Path<String>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -147,6 +162,10 @@ pub async fn get_artifact_by_ref(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact '{}' not found", artifact_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact '{}' not found", artifact_ref)))?;
authorize_artifact_action(&state, &user, Action::Read, &artifact)
.await
.map_err(|_| ApiError::NotFound(format!("Artifact '{}' not found", artifact_ref)))?;
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
Json(ApiResponse::new(ArtifactResponse::from(artifact))), Json(ApiResponse::new(ArtifactResponse::from(artifact))),
@@ -167,7 +186,7 @@ pub async fn get_artifact_by_ref(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn create_artifact( pub async fn create_artifact(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(request): Json<CreateArtifactRequest>, Json(request): Json<CreateArtifactRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -199,6 +218,16 @@ pub async fn create_artifact(
} }
}); });
authorize_artifact_create(
&state,
&user,
&request.r#ref,
request.scope,
&request.owner,
visibility,
)
.await?;
let input = CreateArtifactInput { let input = CreateArtifactInput {
r#ref: request.r#ref, r#ref: request.r#ref,
scope: request.scope, scope: request.scope,
@@ -239,16 +268,18 @@ pub async fn create_artifact(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn update_artifact( pub async fn update_artifact(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
Json(request): Json<UpdateArtifactRequest>, Json(request): Json<UpdateArtifactRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Verify artifact exists // Verify artifact exists
ArtifactRepository::find_by_id(&state.db, id) let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
let input = UpdateArtifactInput { let input = UpdateArtifactInput {
r#ref: None, // Ref is immutable after creation r#ref: None, // Ref is immutable after creation
scope: request.scope, scope: request.scope,
@@ -257,12 +288,27 @@ pub async fn update_artifact(
visibility: request.visibility, visibility: request.visibility,
retention_policy: request.retention_policy, retention_policy: request.retention_policy,
retention_limit: request.retention_limit, retention_limit: request.retention_limit,
name: request.name, name: request.name.map(|patch| match patch {
description: request.description, ArtifactStringPatch::Set(value) => Patch::Set(value),
content_type: request.content_type, ArtifactStringPatch::Clear => Patch::Clear,
}),
description: request.description.map(|patch| match patch {
ArtifactStringPatch::Set(value) => Patch::Set(value),
ArtifactStringPatch::Clear => Patch::Clear,
}),
content_type: request.content_type.map(|patch| match patch {
ArtifactStringPatch::Set(value) => Patch::Set(value),
ArtifactStringPatch::Clear => Patch::Clear,
}),
size_bytes: None, // Managed by version creation trigger size_bytes: None, // Managed by version creation trigger
execution: request.execution.map(Some), execution: request.execution.map(|patch| match patch {
data: request.data, ArtifactExecutionPatch::Set(value) => Patch::Set(value),
ArtifactExecutionPatch::Clear => Patch::Clear,
}),
data: request.data.map(|patch| match patch {
ArtifactJsonPatch::Set(value) => Patch::Set(value),
ArtifactJsonPatch::Clear => Patch::Clear,
}),
}; };
let updated = ArtifactRepository::update(&state.db, id, input).await?; let updated = ArtifactRepository::update(&state.db, id, input).await?;
@@ -289,7 +335,7 @@ pub async fn update_artifact(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn delete_artifact( pub async fn delete_artifact(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -297,6 +343,8 @@ pub async fn delete_artifact(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Delete, &artifact).await?;
// Before deleting DB rows, clean up any file-backed versions on disk // Before deleting DB rows, clean up any file-backed versions on disk
let file_versions = let file_versions =
ArtifactVersionRepository::find_file_versions_by_artifact(&state.db, id).await?; ArtifactVersionRepository::find_file_versions_by_artifact(&state.db, id).await?;
@@ -339,11 +387,17 @@ pub async fn delete_artifact(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn list_artifacts_by_execution( pub async fn list_artifacts_by_execution(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(execution_id): Path<i64>, Path(execution_id): Path<i64>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
let artifacts = ArtifactRepository::find_by_execution(&state.db, execution_id).await?; let mut artifacts = ArtifactRepository::find_by_execution(&state.db, execution_id).await?;
if let Some((identity_id, grants)) = ensure_can_read_any_artifact(&state, &user).await? {
artifacts.retain(|artifact| {
let ctx = artifact_authorization_context(identity_id, artifact);
AuthorizationService::is_allowed(&grants, Resource::Artifacts, Action::Read, &ctx)
});
}
let items: Vec<ArtifactSummary> = artifacts.into_iter().map(ArtifactSummary::from).collect(); let items: Vec<ArtifactSummary> = artifacts.into_iter().map(ArtifactSummary::from).collect();
Ok((StatusCode::OK, Json(ApiResponse::new(items)))) Ok((StatusCode::OK, Json(ApiResponse::new(items))))
@@ -371,7 +425,7 @@ pub async fn list_artifacts_by_execution(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn append_progress( pub async fn append_progress(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
Json(request): Json<AppendProgressRequest>, Json(request): Json<AppendProgressRequest>,
@@ -380,6 +434,8 @@ pub async fn append_progress(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
if artifact.r#type != ArtifactType::Progress { if artifact.r#type != ArtifactType::Progress {
return Err(ApiError::BadRequest(format!( return Err(ApiError::BadRequest(format!(
"Artifact '{}' is type {:?}, not progress. Use version endpoints for file artifacts.", "Artifact '{}' is type {:?}, not progress. Use version endpoints for file artifacts.",
@@ -414,16 +470,18 @@ pub async fn append_progress(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn set_artifact_data( pub async fn set_artifact_data(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
Json(request): Json<SetDataRequest>, Json(request): Json<SetDataRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Verify exists // Verify exists
ArtifactRepository::find_by_id(&state.db, id) let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
let updated = ArtifactRepository::set_data(&state.db, id, &request.data).await?; let updated = ArtifactRepository::set_data(&state.db, id, &request.data).await?;
Ok(( Ok((
@@ -452,15 +510,19 @@ pub async fn set_artifact_data(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn list_versions( pub async fn list_versions(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Verify artifact exists // Verify artifact exists
ArtifactRepository::find_by_id(&state.db, id) let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Read, &artifact)
.await
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
let versions = ArtifactVersionRepository::list_by_artifact(&state.db, id).await?; let versions = ArtifactVersionRepository::list_by_artifact(&state.db, id).await?;
let items: Vec<ArtifactVersionSummary> = versions let items: Vec<ArtifactVersionSummary> = versions
.into_iter() .into_iter()
@@ -486,15 +548,19 @@ pub async fn list_versions(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_version( pub async fn get_version(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path((id, version)): Path<(i64, i32)>, Path((id, version)): Path<(i64, i32)>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Verify artifact exists // Verify artifact exists
ArtifactRepository::find_by_id(&state.db, id) let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Read, &artifact)
.await
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version) let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| {
@@ -520,14 +586,18 @@ pub async fn get_version(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_latest_version( pub async fn get_latest_version(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
ArtifactRepository::find_by_id(&state.db, id) let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Read, &artifact)
.await
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
let ver = ArtifactVersionRepository::find_latest(&state.db, id) let ver = ArtifactVersionRepository::find_latest(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("No versions found for artifact {}", id)))?; .ok_or_else(|| ApiError::NotFound(format!("No versions found for artifact {}", id)))?;
@@ -552,15 +622,17 @@ pub async fn get_latest_version(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn create_version_json( pub async fn create_version_json(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
Json(request): Json<CreateVersionJsonRequest>, Json(request): Json<CreateVersionJsonRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
ArtifactRepository::find_by_id(&state.db, id) let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
let input = CreateArtifactVersionInput { let input = CreateArtifactVersionInput {
artifact: id, artifact: id,
content_type: Some( content_type: Some(
@@ -608,7 +680,7 @@ pub async fn create_version_json(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn create_version_file( pub async fn create_version_file(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
Json(request): Json<CreateFileVersionRequest>, Json(request): Json<CreateFileVersionRequest>,
@@ -617,6 +689,8 @@ pub async fn create_version_file(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
// Validate this is a file-type artifact // Validate this is a file-type artifact
if !is_file_backed_type(artifact.r#type) { if !is_file_backed_type(artifact.r#type) {
return Err(ApiError::BadRequest(format!( return Err(ApiError::BadRequest(format!(
@@ -710,15 +784,17 @@ pub async fn create_version_file(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn upload_version( pub async fn upload_version(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
mut multipart: Multipart, mut multipart: Multipart,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
ArtifactRepository::find_by_id(&state.db, id) let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
let mut file_data: Option<Vec<u8>> = None; let mut file_data: Option<Vec<u8>> = None;
let mut content_type: Option<String> = None; let mut content_type: Option<String> = None;
let mut meta: Option<serde_json::Value> = None; let mut meta: Option<serde_json::Value> = None;
@@ -838,7 +914,7 @@ pub async fn upload_version(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn download_version( pub async fn download_version(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path((id, version)): Path<(i64, i32)>, Path((id, version)): Path<(i64, i32)>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -846,6 +922,10 @@ pub async fn download_version(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Read, &artifact)
.await
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
// First try without content (cheaper query) to check for file_path // First try without content (cheaper query) to check for file_path
let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version) let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version)
.await? .await?
@@ -888,7 +968,7 @@ pub async fn download_version(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn download_latest( pub async fn download_latest(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -896,6 +976,10 @@ pub async fn download_latest(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Read, &artifact)
.await
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
// First try without content (cheaper query) to check for file_path // First try without content (cheaper query) to check for file_path
let ver = ArtifactVersionRepository::find_latest(&state.db, id) let ver = ArtifactVersionRepository::find_latest(&state.db, id)
.await? .await?
@@ -939,7 +1023,7 @@ pub async fn download_latest(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn delete_version( pub async fn delete_version(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path((id, version)): Path<(i64, i32)>, Path((id, version)): Path<(i64, i32)>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -948,6 +1032,8 @@ pub async fn delete_version(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Delete, &artifact).await?;
// Find the version by artifact + version number // Find the version by artifact + version number
let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version) let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version)
.await? .await?
@@ -1026,7 +1112,7 @@ pub async fn delete_version(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn upload_version_by_ref( pub async fn upload_version_by_ref(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(artifact_ref): Path<String>, Path(artifact_ref): Path<String>,
mut multipart: Multipart, mut multipart: Multipart,
@@ -1141,6 +1227,8 @@ pub async fn upload_version_by_ref(
// Upsert: find existing artifact or create a new one // Upsert: find existing artifact or create a new one
let artifact = match ArtifactRepository::find_by_ref(&state.db, &artifact_ref).await? { let artifact = match ArtifactRepository::find_by_ref(&state.db, &artifact_ref).await? {
Some(existing) => { Some(existing) => {
authorize_artifact_action(&state, &user, Action::Update, &existing).await?;
// Update execution link if a new execution ID was provided // Update execution link if a new execution ID was provided
if execution_id.is_some() && execution_id != existing.execution { if execution_id.is_some() && execution_id != existing.execution {
let update_input = UpdateArtifactInput { let update_input = UpdateArtifactInput {
@@ -1155,7 +1243,7 @@ pub async fn upload_version_by_ref(
description: None, description: None,
content_type: None, content_type: None,
size_bytes: None, size_bytes: None,
execution: execution_id.map(Some), execution: execution_id.map(Patch::Set),
data: None, data: None,
}; };
ArtifactRepository::update(&state.db, existing.id, update_input).await? ArtifactRepository::update(&state.db, existing.id, update_input).await?
@@ -1195,6 +1283,16 @@ pub async fn upload_version_by_ref(
} }
}; };
authorize_artifact_create(
&state,
&user,
&artifact_ref,
a_scope,
owner.as_deref().unwrap_or_default(),
a_visibility,
)
.await?;
// Parse retention // Parse retention
let a_retention_policy: RetentionPolicyType = match &retention_policy { let a_retention_policy: RetentionPolicyType = match &retention_policy {
Some(rp) if !rp.is_empty() => { Some(rp) if !rp.is_empty() => {
@@ -1281,7 +1379,7 @@ pub async fn upload_version_by_ref(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn allocate_file_version_by_ref( pub async fn allocate_file_version_by_ref(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(artifact_ref): Path<String>, Path(artifact_ref): Path<String>,
Json(request): Json<AllocateFileVersionByRefRequest>, Json(request): Json<AllocateFileVersionByRefRequest>,
@@ -1289,6 +1387,8 @@ pub async fn allocate_file_version_by_ref(
// Upsert: find existing artifact or create a new one // Upsert: find existing artifact or create a new one
let artifact = match ArtifactRepository::find_by_ref(&state.db, &artifact_ref).await? { let artifact = match ArtifactRepository::find_by_ref(&state.db, &artifact_ref).await? {
Some(existing) => { Some(existing) => {
authorize_artifact_action(&state, &user, Action::Update, &existing).await?;
// Update execution link if a new execution ID was provided // Update execution link if a new execution ID was provided
if request.execution.is_some() && request.execution != existing.execution { if request.execution.is_some() && request.execution != existing.execution {
let update_input = UpdateArtifactInput { let update_input = UpdateArtifactInput {
@@ -1303,7 +1403,7 @@ pub async fn allocate_file_version_by_ref(
description: None, description: None,
content_type: None, content_type: None,
size_bytes: None, size_bytes: None,
execution: request.execution.map(Some), execution: request.execution.map(Patch::Set),
data: None, data: None,
}; };
ArtifactRepository::update(&state.db, existing.id, update_input).await? ArtifactRepository::update(&state.db, existing.id, update_input).await?
@@ -1331,6 +1431,16 @@ pub async fn allocate_file_version_by_ref(
.unwrap_or(RetentionPolicyType::Versions); .unwrap_or(RetentionPolicyType::Versions);
let a_retention_limit = request.retention_limit.unwrap_or(10); let a_retention_limit = request.retention_limit.unwrap_or(10);
authorize_artifact_create(
&state,
&user,
&artifact_ref,
a_scope,
request.owner.as_deref().unwrap_or_default(),
a_visibility,
)
.await?;
let create_input = CreateArtifactInput { let create_input = CreateArtifactInput {
r#ref: artifact_ref.clone(), r#ref: artifact_ref.clone(),
scope: a_scope, scope: a_scope,
@@ -1421,6 +1531,105 @@ pub async fn allocate_file_version_by_ref(
// Helpers // Helpers
// ============================================================================ // ============================================================================
async fn authorize_artifact_action(
state: &Arc<AppState>,
user: &AuthenticatedUser,
action: Action,
artifact: &attune_common::models::artifact::Artifact,
) -> Result<(), ApiError> {
if user.claims.token_type != TokenType::Access {
return Ok(());
}
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
authz
.authorize(
user,
AuthorizationCheck {
resource: Resource::Artifacts,
action,
context: artifact_authorization_context(identity_id, artifact),
},
)
.await
}
async fn authorize_artifact_create(
state: &Arc<AppState>,
user: &AuthenticatedUser,
artifact_ref: &str,
scope: OwnerType,
owner: &str,
visibility: ArtifactVisibility,
) -> Result<(), ApiError> {
if user.claims.token_type != TokenType::Access {
return Ok(());
}
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_ref = Some(artifact_ref.to_string());
ctx.owner_type = Some(scope);
ctx.owner_ref = Some(owner.to_string());
ctx.visibility = Some(visibility);
authz
.authorize(
user,
AuthorizationCheck {
resource: Resource::Artifacts,
action: Action::Create,
context: ctx,
},
)
.await
}
async fn ensure_can_read_any_artifact(
state: &Arc<AppState>,
user: &AuthenticatedUser,
) -> Result<Option<(i64, Vec<attune_common::rbac::Grant>)>, ApiError> {
if user.claims.token_type != TokenType::Access {
return Ok(None);
}
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let grants = authz.effective_grants(user).await?;
let can_read_any_artifact = grants
.iter()
.any(|g| g.resource == Resource::Artifacts && g.actions.contains(&Action::Read));
if !can_read_any_artifact {
return Err(ApiError::Forbidden(
"Insufficient permissions: artifacts:read".to_string(),
));
}
Ok(Some((identity_id, grants)))
}
fn artifact_authorization_context(
identity_id: i64,
artifact: &attune_common::models::artifact::Artifact,
) -> AuthorizationContext {
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_id = Some(artifact.id);
ctx.target_ref = Some(artifact.r#ref.clone());
ctx.owner_type = Some(artifact.scope);
ctx.owner_ref = Some(artifact.owner.clone());
ctx.visibility = Some(artifact.visibility);
ctx
}
/// Returns true for artifact types that should use file-backed storage on disk. /// Returns true for artifact types that should use file-backed storage on disk.
fn is_file_backed_type(artifact_type: ArtifactType) -> bool { fn is_file_backed_type(artifact_type: ArtifactType) -> bool {
matches!( matches!(
@@ -1759,14 +1968,19 @@ pub async fn stream_artifact(
let token = params.token.as_ref().ok_or(ApiError::Unauthorized( let token = params.token.as_ref().ok_or(ApiError::Unauthorized(
"Missing authentication token".to_string(), "Missing authentication token".to_string(),
))?; ))?;
validate_token(token, &state.jwt_config) let claims = validate_token(token, &state.jwt_config)
.map_err(|_| ApiError::Unauthorized("Invalid authentication token".to_string()))?; .map_err(|_| ApiError::Unauthorized("Invalid authentication token".to_string()))?;
let user = AuthenticatedUser { claims };
// --- resolve artifact + latest version --------------------------------- // --- resolve artifact + latest version ---------------------------------
let artifact = ArtifactRepository::find_by_id(&state.db, id) let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Read, &artifact)
.await
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
if !is_file_backed_type(artifact.r#type) { if !is_file_backed_type(artifact.r#type) {
return Err(ApiError::BadRequest(format!( return Err(ApiError::BadRequest(format!(
"Artifact '{}' is type {:?} which is not file-backed. \ "Artifact '{}' is type {:?} which is not file-backed. \

View File

@@ -1,7 +1,9 @@
//! Authentication routes //! Authentication routes
use axum::{ use axum::{
extract::State, extract::{Query, State},
http::HeaderMap,
response::{IntoResponse, Redirect, Response},
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
@@ -21,11 +23,16 @@ use crate::{
TokenType, TokenType,
}, },
middleware::RequireAuth, middleware::RequireAuth,
oidc::{
apply_cookies_to_headers, build_login_redirect, build_logout_redirect,
cookie_authenticated_user, get_cookie_value, oidc_callback_redirect_response,
OidcCallbackQuery, REFRESH_COOKIE_NAME,
},
verify_password, verify_password,
}, },
dto::{ dto::{
ApiResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest, ApiResponse, AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse,
RegisterRequest, SuccessResponse, TokenResponse, LoginRequest, RefreshTokenRequest, RegisterRequest, SuccessResponse, TokenResponse,
}, },
middleware::error::ApiError, middleware::error::ApiError,
state::SharedState, state::SharedState,
@@ -63,7 +70,12 @@ pub struct SensorTokenResponse {
/// Create authentication routes /// Create authentication routes
pub fn routes() -> Router<SharedState> { pub fn routes() -> Router<SharedState> {
Router::new() Router::new()
.route("/settings", get(auth_settings))
.route("/login", post(login)) .route("/login", post(login))
.route("/oidc/login", get(oidc_login))
.route("/callback", get(oidc_callback))
.route("/ldap/login", post(ldap_login))
.route("/logout", get(logout))
.route("/register", post(register)) .route("/register", post(register))
.route("/refresh", post(refresh_token)) .route("/refresh", post(refresh_token))
.route("/me", get(get_current_user)) .route("/me", get(get_current_user))
@@ -72,6 +84,63 @@ pub fn routes() -> Router<SharedState> {
.route("/internal/sensor-token", post(create_sensor_token_internal)) .route("/internal/sensor-token", post(create_sensor_token_internal))
} }
/// Authentication settings endpoint
///
/// GET /auth/settings
#[utoipa::path(
get,
path = "/auth/settings",
tag = "auth",
responses(
(status = 200, description = "Authentication settings", body = inline(ApiResponse<AuthSettingsResponse>))
)
)]
pub async fn auth_settings(
State(state): State<SharedState>,
) -> Result<Json<ApiResponse<AuthSettingsResponse>>, ApiError> {
let oidc = state
.config
.security
.oidc
.as_ref()
.filter(|oidc| oidc.enabled);
let ldap = state
.config
.security
.ldap
.as_ref()
.filter(|ldap| ldap.enabled);
let response = AuthSettingsResponse {
authentication_enabled: state.config.security.enable_auth,
local_password_enabled: state.config.security.enable_auth,
local_password_visible_by_default: state.config.security.enable_auth
&& state.config.security.login_page.show_local_login,
oidc_enabled: oidc.is_some(),
oidc_visible_by_default: oidc.is_some() && state.config.security.login_page.show_oidc_login,
oidc_provider_name: oidc.map(|oidc| oidc.provider_name.clone()),
oidc_provider_label: oidc.map(|oidc| {
oidc.provider_label
.clone()
.unwrap_or_else(|| oidc.provider_name.clone())
}),
oidc_provider_icon_url: oidc.and_then(|oidc| oidc.provider_icon_url.clone()),
ldap_enabled: ldap.is_some(),
ldap_visible_by_default: ldap.is_some() && state.config.security.login_page.show_ldap_login,
ldap_provider_name: ldap.map(|ldap| ldap.provider_name.clone()),
ldap_provider_label: ldap.map(|ldap| {
ldap.provider_label
.clone()
.unwrap_or_else(|| ldap.provider_name.clone())
}),
ldap_provider_icon_url: ldap.and_then(|ldap| ldap.provider_icon_url.clone()),
self_registration_enabled: state.config.security.allow_self_registration,
};
Ok(Json(ApiResponse::new(response)))
}
/// Login endpoint /// Login endpoint
/// ///
/// POST /auth/login /// POST /auth/login
@@ -100,6 +169,12 @@ pub async fn login(
.await? .await?
.ok_or_else(|| ApiError::Unauthorized("Invalid login or password".to_string()))?; .ok_or_else(|| ApiError::Unauthorized("Invalid login or password".to_string()))?;
if identity.frozen {
return Err(ApiError::Forbidden(
"Identity is frozen and cannot authenticate".to_string(),
));
}
// Check if identity has a password set // Check if identity has a password set
let password_hash = identity let password_hash = identity
.password_hash .password_hash
@@ -152,6 +227,12 @@ pub async fn register(
State(state): State<SharedState>, State(state): State<SharedState>,
Json(payload): Json<RegisterRequest>, Json(payload): Json<RegisterRequest>,
) -> Result<Json<ApiResponse<TokenResponse>>, ApiError> { ) -> Result<Json<ApiResponse<TokenResponse>>, ApiError> {
if !state.config.security.allow_self_registration {
return Err(ApiError::Forbidden(
"Self-service registration is disabled; identities must be provisioned by an administrator or identity provider".to_string(),
));
}
// Validate request // Validate request
payload payload
.validate() .validate()
@@ -171,7 +252,7 @@ pub async fn register(
// Hash password // Hash password
let password_hash = hash_password(&payload.password)?; let password_hash = hash_password(&payload.password)?;
// Create identity with password hash // Registration creates an identity only; permission assignments are managed separately.
let input = CreateIdentityInput { let input = CreateIdentityInput {
login: payload.login.clone(), login: payload.login.clone(),
display_name: payload.display_name, display_name: payload.display_name,
@@ -215,15 +296,22 @@ pub async fn register(
)] )]
pub async fn refresh_token( pub async fn refresh_token(
State(state): State<SharedState>, State(state): State<SharedState>,
Json(payload): Json<RefreshTokenRequest>, headers: HeaderMap,
) -> Result<Json<ApiResponse<TokenResponse>>, ApiError> { payload: Option<Json<RefreshTokenRequest>>,
// Validate request ) -> Result<Response, ApiError> {
payload let browser_cookie_refresh = payload.is_none();
.validate() let refresh_token = if let Some(Json(payload)) = payload {
.map_err(|e| ApiError::ValidationError(format!("Invalid refresh token request: {}", e)))?; payload.validate().map_err(|e| {
ApiError::ValidationError(format!("Invalid refresh token request: {}", e))
})?;
payload.refresh_token
} else {
get_cookie_value(&headers, REFRESH_COOKIE_NAME)
.ok_or_else(|| ApiError::Unauthorized("Missing refresh token".to_string()))?
};
// Validate refresh token // Validate refresh token
let claims = validate_token(&payload.refresh_token, &state.jwt_config) let claims = validate_token(&refresh_token, &state.jwt_config)
.map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".to_string()))?; .map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".to_string()))?;
// Ensure it's a refresh token // Ensure it's a refresh token
@@ -242,6 +330,12 @@ pub async fn refresh_token(
.await? .await?
.ok_or_else(|| ApiError::Unauthorized("Identity not found".to_string()))?; .ok_or_else(|| ApiError::Unauthorized("Identity not found".to_string()))?;
if identity.frozen {
return Err(ApiError::Forbidden(
"Identity is frozen and cannot authenticate".to_string(),
));
}
// Generate new tokens // Generate new tokens
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?; let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?; let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
@@ -251,8 +345,18 @@ pub async fn refresh_token(
refresh_token, refresh_token,
state.jwt_config.access_token_expiration, state.jwt_config.access_token_expiration,
); );
let response_body = Json(ApiResponse::new(response.clone()));
Ok(Json(ApiResponse::new(response))) if browser_cookie_refresh {
let mut http_response = response_body.into_response();
apply_cookies_to_headers(
http_response.headers_mut(),
&crate::auth::oidc::build_auth_cookies(&state, &response, ""),
)?;
return Ok(http_response);
}
Ok(response_body.into_response())
} }
/// Get current user endpoint /// Get current user endpoint
@@ -273,15 +377,27 @@ pub async fn refresh_token(
)] )]
pub async fn get_current_user( pub async fn get_current_user(
State(state): State<SharedState>, State(state): State<SharedState>,
RequireAuth(user): RequireAuth, headers: HeaderMap,
user: Result<RequireAuth, crate::auth::middleware::AuthError>,
) -> Result<Json<ApiResponse<CurrentUserResponse>>, ApiError> { ) -> Result<Json<ApiResponse<CurrentUserResponse>>, ApiError> {
let identity_id = user.identity_id()?; let authenticated_user = match user {
Ok(RequireAuth(user)) => user,
Err(_) => cookie_authenticated_user(&headers, &state)?
.ok_or_else(|| ApiError::Unauthorized("Unauthorized".to_string()))?,
};
let identity_id = authenticated_user.identity_id()?;
// Fetch identity from database // Fetch identity from database
let identity = IdentityRepository::find_by_id(&state.db, identity_id) let identity = IdentityRepository::find_by_id(&state.db, identity_id)
.await? .await?
.ok_or_else(|| ApiError::NotFound("Identity not found".to_string()))?; .ok_or_else(|| ApiError::NotFound("Identity not found".to_string()))?;
if identity.frozen {
return Err(ApiError::Forbidden(
"Identity is frozen and cannot authenticate".to_string(),
));
}
let response = CurrentUserResponse { let response = CurrentUserResponse {
id: identity.id, id: identity.id,
login: identity.login, login: identity.login,
@@ -291,6 +407,106 @@ pub async fn get_current_user(
Ok(Json(ApiResponse::new(response))) Ok(Json(ApiResponse::new(response)))
} }
/// Request body for LDAP login.
#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)]
pub struct LdapLoginRequest {
/// User login name (uid, sAMAccountName, etc.)
#[validate(length(min = 1, max = 255))]
pub login: String,
/// User password
#[validate(length(min = 1, max = 512))]
pub password: String,
}
#[derive(Debug, Deserialize)]
pub struct OidcLoginParams {
pub redirect_to: Option<String>,
}
/// Begin browser OIDC login by redirecting to the provider.
pub async fn oidc_login(
State(state): State<SharedState>,
Query(params): Query<OidcLoginParams>,
) -> Result<Response, ApiError> {
let login_redirect = build_login_redirect(&state, params.redirect_to.as_deref()).await?;
let mut response = Redirect::temporary(&login_redirect.authorization_url).into_response();
apply_cookies_to_headers(response.headers_mut(), &login_redirect.cookies)?;
Ok(response)
}
/// Handle the OIDC authorization code callback.
pub async fn oidc_callback(
State(state): State<SharedState>,
headers: HeaderMap,
Query(query): Query<OidcCallbackQuery>,
) -> Result<Response, ApiError> {
let redirect_to = get_cookie_value(&headers, crate::auth::oidc::OIDC_REDIRECT_COOKIE_NAME);
let authenticated = crate::auth::oidc::handle_callback(&state, &headers, &query).await?;
oidc_callback_redirect_response(
&state,
&authenticated.token_response,
redirect_to,
&authenticated.id_token,
)
}
/// Authenticate via LDAP directory.
///
/// POST /auth/ldap/login
#[utoipa::path(
post,
path = "/auth/ldap/login",
tag = "auth",
request_body = LdapLoginRequest,
responses(
(status = 200, description = "Successfully authenticated via LDAP", body = inline(ApiResponse<TokenResponse>)),
(status = 401, description = "Invalid LDAP credentials"),
(status = 501, description = "LDAP not configured")
)
)]
pub async fn ldap_login(
State(state): State<SharedState>,
Json(payload): Json<LdapLoginRequest>,
) -> Result<Json<ApiResponse<TokenResponse>>, ApiError> {
payload
.validate()
.map_err(|e| ApiError::ValidationError(format!("Invalid LDAP login request: {e}")))?;
let authenticated =
crate::auth::ldap::authenticate(&state, &payload.login, &payload.password).await?;
Ok(Json(ApiResponse::new(authenticated.token_response)))
}
/// Logout the current browser session and optionally redirect through the provider logout flow.
pub async fn logout(
State(state): State<SharedState>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
let oidc_enabled = state
.config
.security
.oidc
.as_ref()
.is_some_and(|oidc| oidc.enabled);
let response = if oidc_enabled {
let logout_redirect = build_logout_redirect(&state, &headers).await?;
let mut response = Redirect::temporary(&logout_redirect.redirect_url).into_response();
apply_cookies_to_headers(response.headers_mut(), &logout_redirect.cookies)?;
response
} else {
let mut response = Redirect::temporary("/login").into_response();
apply_cookies_to_headers(
response.headers_mut(),
&crate::auth::oidc::clear_auth_cookies(&state),
)?;
response
};
Ok(response)
}
/// Change password endpoint /// Change password endpoint
/// ///
/// POST /auth/change-password /// POST /auth/change-password
@@ -353,6 +569,7 @@ pub async fn change_password(
display_name: None, display_name: None,
password_hash: Some(new_password_hash), password_hash: Some(new_password_hash),
attributes: None, attributes: None,
frozen: None,
}; };
IdentityRepository::update(&state.db, identity_id, update_input).await?; IdentityRepository::update(&state.db, identity_id, update_input).await?;

View File

@@ -82,6 +82,17 @@ pub async fn create_event(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(payload): Json<CreateEventRequest>, Json(payload): Json<CreateEventRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Only sensor and execution tokens may create events directly.
// User sessions must go through the webhook receiver instead.
use crate::auth::jwt::TokenType;
if user.0.claims.token_type == TokenType::Access {
return Err(ApiError::Forbidden(
"Events may only be created by sensor services. To fire an event as a user, \
enable webhooks on the trigger and POST to its webhook URL."
.to_string(),
));
}
// Validate request // Validate request
payload payload
.validate() .validate()
@@ -128,7 +139,6 @@ pub async fn create_event(
}; };
// Determine source (sensor) from authenticated user if it's a sensor token // Determine source (sensor) from authenticated user if it's a sensor token
use crate::auth::jwt::TokenType;
let (source_id, source_ref) = match user.0.claims.token_type { let (source_id, source_ref) = match user.0.claims.token_type {
TokenType::Sensor => { TokenType::Sensor => {
// Extract sensor reference from login // Extract sensor reference from login

View File

@@ -10,6 +10,7 @@ use axum::{
routing::get, routing::get,
Json, Router, Json, Router,
}; };
use chrono::Utc;
use futures::stream::{Stream, StreamExt}; use futures::stream::{Stream, StreamExt};
use std::sync::Arc; use std::sync::Arc;
use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::BroadcastStream;
@@ -24,13 +25,15 @@ use attune_common::repositories::{
execution::{ execution::{
CreateExecutionInput, ExecutionRepository, ExecutionSearchFilters, UpdateExecutionInput, CreateExecutionInput, ExecutionRepository, ExecutionSearchFilters, UpdateExecutionInput,
}, },
workflow::WorkflowExecutionRepository, workflow::{WorkflowDefinitionRepository, WorkflowExecutionRepository},
Create, FindById, FindByRef, Update, Create, FindById, FindByRef, Update,
}; };
use attune_common::workflow::{CancellationPolicy, WorkflowDefinition};
use sqlx::Row; use sqlx::Row;
use crate::{ use crate::{
auth::middleware::RequireAuth, auth::middleware::RequireAuth,
authz::{AuthorizationCheck, AuthorizationService},
dto::{ dto::{
common::{PaginatedResponse, PaginationParams}, common::{PaginatedResponse, PaginationParams},
execution::{ execution::{
@@ -41,6 +44,7 @@ use crate::{
middleware::{ApiError, ApiResult}, middleware::{ApiError, ApiResult},
state::AppState, state::AppState,
}; };
use attune_common::rbac::{Action, AuthorizationContext, Resource};
/// Create a new execution (manual execution) /// Create a new execution (manual execution)
/// ///
@@ -60,7 +64,7 @@ use crate::{
)] )]
pub async fn create_execution( pub async fn create_execution(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
Json(request): Json<CreateExecutionRequest>, Json(request): Json<CreateExecutionRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Validate that the action exists // Validate that the action exists
@@ -68,6 +72,29 @@ pub async fn create_execution(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Action '{}' not found", request.action_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Action '{}' not found", request.action_ref)))?;
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut action_ctx = AuthorizationContext::new(identity_id);
action_ctx.target_id = Some(action.id);
action_ctx.target_ref = Some(action.r#ref.clone());
action_ctx.pack_ref = Some(action.pack_ref.clone());
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Actions,
action: Action::Execute,
context: action_ctx,
},
)
.await?;
}
// Create execution input // Create execution input
let execution_input = CreateExecutionInput { let execution_input = CreateExecutionInput {
action: Some(action.id), action: Some(action.id),
@@ -83,6 +110,7 @@ pub async fn create_execution(
parent: None, parent: None,
enforcement: None, enforcement: None,
executor: None, executor: None,
worker: None,
status: ExecutionStatus::Requested, status: ExecutionStatus::Requested,
result: None, result: None,
workflow_task: None, // Non-workflow execution workflow_task: None, // Non-workflow execution
@@ -439,9 +467,17 @@ pub async fn cancel_execution(
..Default::default() ..Default::default()
}; };
let updated = ExecutionRepository::update(&state.db, id, update).await?; let updated = ExecutionRepository::update(&state.db, id, update).await?;
let delegated_to_executor = publish_status_change_to_executor(
publisher.as_deref(),
&execution,
ExecutionStatus::Cancelled,
"api-service",
)
.await;
// Cascade to workflow children if this is a workflow execution if !delegated_to_executor {
cancel_workflow_children(&state.db, publisher.as_deref(), id).await; cancel_workflow_children(&state.db, publisher.as_deref(), id).await;
}
let response = ApiResponse::new(ExecutionResponse::from(updated)); let response = ApiResponse::new(ExecutionResponse::from(updated));
return Ok((StatusCode::OK, Json(response))); return Ok((StatusCode::OK, Json(response)));
@@ -453,19 +489,27 @@ pub async fn cancel_execution(
..Default::default() ..Default::default()
}; };
let updated = ExecutionRepository::update(&state.db, id, update).await?; let updated = ExecutionRepository::update(&state.db, id, update).await?;
let delegated_to_executor = publish_status_change_to_executor(
publisher.as_deref(),
&execution,
ExecutionStatus::Canceling,
"api-service",
)
.await;
// Send cancel request to the worker via MQ // Send cancel request to the worker via MQ
if let Some(worker_id) = execution.executor { if let Some(worker_id) = execution.worker {
send_cancel_to_worker(publisher.as_deref(), id, worker_id).await; send_cancel_to_worker(publisher.as_deref(), id, worker_id).await;
} else { } else {
tracing::warn!( tracing::warn!(
"Execution {} has no executor/worker assigned; marked as canceling but no MQ message sent", "Execution {} has no worker assigned; marked as canceling but no MQ message sent",
id id
); );
} }
// Cascade to workflow children if this is a workflow execution if !delegated_to_executor {
cancel_workflow_children(&state.db, publisher.as_deref(), id).await; cancel_workflow_children(&state.db, publisher.as_deref(), id).await;
}
let response = ApiResponse::new(ExecutionResponse::from(updated)); let response = ApiResponse::new(ExecutionResponse::from(updated));
Ok((StatusCode::OK, Json(response))) Ok((StatusCode::OK, Json(response)))
@@ -503,6 +547,89 @@ async fn send_cancel_to_worker(publisher: Option<&Publisher>, execution_id: i64,
} }
} }
async fn publish_status_change_to_executor(
publisher: Option<&Publisher>,
execution: &attune_common::models::Execution,
new_status: ExecutionStatus,
source: &str,
) -> bool {
let Some(publisher) = publisher else {
return false;
};
let new_status = match new_status {
ExecutionStatus::Requested => "requested",
ExecutionStatus::Scheduling => "scheduling",
ExecutionStatus::Scheduled => "scheduled",
ExecutionStatus::Running => "running",
ExecutionStatus::Completed => "completed",
ExecutionStatus::Failed => "failed",
ExecutionStatus::Canceling => "canceling",
ExecutionStatus::Cancelled => "cancelled",
ExecutionStatus::Timeout => "timeout",
ExecutionStatus::Abandoned => "abandoned",
};
let payload = attune_common::mq::ExecutionStatusChangedPayload {
execution_id: execution.id,
action_ref: execution.action_ref.clone(),
previous_status: format!("{:?}", execution.status).to_lowercase(),
new_status: new_status.to_string(),
changed_at: Utc::now(),
};
let envelope = MessageEnvelope::new(MessageType::ExecutionStatusChanged, payload)
.with_source(source)
.with_correlation_id(uuid::Uuid::new_v4());
if let Err(e) = publisher.publish_envelope(&envelope).await {
tracing::error!(
"Failed to publish status change for execution {} to executor: {}",
execution.id,
e
);
return false;
}
true
}
/// Resolve the [`CancellationPolicy`] for a workflow parent execution.
///
/// Looks up the `workflow_execution` → `workflow_definition` chain and
/// deserialises the stored definition to extract the policy. Returns
/// [`CancellationPolicy::AllowFinish`] (the default) when any lookup
/// step fails so that the safest behaviour is used as a fallback.
async fn resolve_cancellation_policy(
db: &sqlx::PgPool,
parent_execution_id: i64,
) -> CancellationPolicy {
let wf_exec =
match WorkflowExecutionRepository::find_by_execution(db, parent_execution_id).await {
Ok(Some(wf)) => wf,
_ => return CancellationPolicy::default(),
};
let wf_def = match WorkflowDefinitionRepository::find_by_id(db, wf_exec.workflow_def).await {
Ok(Some(def)) => def,
_ => return CancellationPolicy::default(),
};
// Deserialise the stored JSON definition to extract the policy field.
match serde_json::from_value::<WorkflowDefinition>(wf_def.definition) {
Ok(def) => def.cancellation_policy,
Err(e) => {
tracing::warn!(
"Failed to deserialise workflow definition for workflow_def {}: {}. \
Falling back to AllowFinish cancellation policy.",
wf_exec.workflow_def,
e
);
CancellationPolicy::default()
}
}
}
/// Cancel all incomplete child executions of a workflow parent execution. /// Cancel all incomplete child executions of a workflow parent execution.
/// ///
/// This handles the workflow cascade: when a workflow execution is cancelled, /// This handles the workflow cascade: when a workflow execution is cancelled,
@@ -510,13 +637,35 @@ async fn send_cancel_to_worker(publisher: Option<&Publisher>, execution_id: i64,
/// Additionally, the `workflow_execution` record is marked Cancelled so the /// Additionally, the `workflow_execution` record is marked Cancelled so the
/// scheduler's `advance_workflow` will short-circuit and not dispatch new tasks. /// scheduler's `advance_workflow` will short-circuit and not dispatch new tasks.
/// ///
/// Children in pre-running states (Requested, Scheduling, Scheduled) are set /// Behaviour depends on the workflow's [`CancellationPolicy`]:
/// to Cancelled immediately. Children that are Running receive a cancel MQ ///
/// message so their worker can gracefully stop the process. /// - **`AllowFinish`** (default): Children in pre-running states (Requested,
/// Scheduling, Scheduled) are set to Cancelled immediately. Running children
/// are left alone and will complete naturally; `advance_workflow` sees the
/// cancelled `workflow_execution` and will not dispatch further tasks.
///
/// - **`CancelRunning`**: Pre-running children are cancelled as above.
/// Running children also receive a cancel MQ message so their worker can
/// gracefully stop the process (SIGINT → SIGTERM → SIGKILL).
async fn cancel_workflow_children( async fn cancel_workflow_children(
db: &sqlx::PgPool, db: &sqlx::PgPool,
publisher: Option<&Publisher>, publisher: Option<&Publisher>,
parent_execution_id: i64, parent_execution_id: i64,
) {
// Determine the cancellation policy from the workflow definition.
let policy = resolve_cancellation_policy(db, parent_execution_id).await;
cancel_workflow_children_with_policy(db, publisher, parent_execution_id, policy).await;
}
/// Inner implementation that carries the resolved [`CancellationPolicy`]
/// through recursive calls so that nested child workflows inherit the
/// top-level policy.
async fn cancel_workflow_children_with_policy(
db: &sqlx::PgPool,
publisher: Option<&Publisher>,
parent_execution_id: i64,
policy: CancellationPolicy,
) { ) {
// Find all child executions that are still incomplete // Find all child executions that are still incomplete
let children: Vec<attune_common::models::Execution> = match sqlx::query_as::< let children: Vec<attune_common::models::Execution> = match sqlx::query_as::<
@@ -546,9 +695,10 @@ async fn cancel_workflow_children(
} }
tracing::info!( tracing::info!(
"Cascading cancellation from execution {} to {} child execution(s)", "Cascading cancellation from execution {} to {} child execution(s) (policy: {:?})",
parent_execution_id, parent_execution_id,
children.len() children.len(),
policy,
); );
for child in &children { for child in &children {
@@ -558,7 +708,7 @@ async fn cancel_workflow_children(
child.status, child.status,
ExecutionStatus::Requested | ExecutionStatus::Scheduling | ExecutionStatus::Scheduled ExecutionStatus::Requested | ExecutionStatus::Scheduling | ExecutionStatus::Scheduled
) { ) {
// Pre-running: cancel immediately in DB // Pre-running: cancel immediately in DB (both policies)
let update = UpdateExecutionInput { let update = UpdateExecutionInput {
status: Some(ExecutionStatus::Cancelled), status: Some(ExecutionStatus::Cancelled),
result: Some(serde_json::json!({ result: Some(serde_json::json!({
@@ -575,29 +725,45 @@ async fn cancel_workflow_children(
child.status, child.status,
ExecutionStatus::Running | ExecutionStatus::Canceling ExecutionStatus::Running | ExecutionStatus::Canceling
) { ) {
// Running: set to Canceling and send MQ message to the worker match policy {
if child.status != ExecutionStatus::Canceling { CancellationPolicy::CancelRunning => {
let update = UpdateExecutionInput { // Running: set to Canceling and send MQ message to the worker
status: Some(ExecutionStatus::Canceling), if child.status != ExecutionStatus::Canceling {
..Default::default() let update = UpdateExecutionInput {
}; status: Some(ExecutionStatus::Canceling),
if let Err(e) = ExecutionRepository::update(db, child_id, update).await { ..Default::default()
tracing::error!( };
"Failed to set child execution {} to canceling: {}", if let Err(e) = ExecutionRepository::update(db, child_id, update).await {
child_id, tracing::error!(
e "Failed to set child execution {} to canceling: {}",
child_id,
e
);
}
}
if let Some(worker_id) = child.worker {
send_cancel_to_worker(publisher, child_id, worker_id).await;
}
}
CancellationPolicy::AllowFinish => {
// Running tasks are allowed to complete naturally.
// advance_workflow will see the cancelled workflow_execution
// and will not dispatch any further tasks.
tracing::info!(
"AllowFinish policy: leaving running child execution {} alone",
child_id
); );
} }
} }
if let Some(worker_id) = child.executor {
send_cancel_to_worker(publisher, child_id, worker_id).await;
}
} }
// Recursively cancel grandchildren (nested workflows) // Recursively cancel grandchildren (nested workflows)
// Use Box::pin to allow the recursive async call // Use Box::pin to allow the recursive async call
Box::pin(cancel_workflow_children(db, publisher, child_id)).await; Box::pin(cancel_workflow_children_with_policy(
db, publisher, child_id, policy,
))
.await;
} }
// Also mark any associated workflow_execution record as Cancelled so that // Also mark any associated workflow_execution record as Cancelled so that
@@ -634,6 +800,56 @@ async fn cancel_workflow_children(
} }
} }
} }
// If no children are still running (all were pre-running or were
// cancelled), finalize the parent execution as Cancelled immediately.
// Without this, the parent would stay stuck in "Canceling" because no
// task completion would trigger advance_workflow to finalize it.
let still_running: Vec<attune_common::models::Execution> = match sqlx::query_as::<
_,
attune_common::models::Execution,
>(&format!(
"SELECT {} FROM execution WHERE parent = $1 AND status IN ('running', 'canceling', 'scheduling', 'scheduled', 'requested')",
attune_common::repositories::execution::SELECT_COLUMNS
))
.bind(parent_execution_id)
.fetch_all(db)
.await
{
Ok(rows) => rows,
Err(e) => {
tracing::error!(
"Failed to check remaining children for parent {}: {}",
parent_execution_id,
e
);
return;
}
};
if still_running.is_empty() {
// No children left in flight — finalize the parent execution now.
let update = UpdateExecutionInput {
status: Some(ExecutionStatus::Cancelled),
result: Some(serde_json::json!({
"error": "Workflow cancelled",
"succeeded": false,
})),
..Default::default()
};
if let Err(e) = ExecutionRepository::update(db, parent_execution_id, update).await {
tracing::error!(
"Failed to finalize parent execution {} as Cancelled: {}",
parent_execution_id,
e
);
} else {
tracing::info!(
"Finalized parent execution {} as Cancelled (no running children remain)",
parent_execution_id
);
}
}
} }
/// Create execution routes /// Create execution routes

View File

@@ -10,7 +10,6 @@ use axum::{
use std::sync::Arc; use std::sync::Arc;
use validator::Validate; use validator::Validate;
use attune_common::models::OwnerType;
use attune_common::repositories::{ use attune_common::repositories::{
action::ActionRepository, action::ActionRepository,
key::{CreateKeyInput, KeyRepository, KeySearchFilters, UpdateKeyInput}, key::{CreateKeyInput, KeyRepository, KeySearchFilters, UpdateKeyInput},
@@ -18,9 +17,14 @@ use attune_common::repositories::{
trigger::SensorRepository, trigger::SensorRepository,
Create, Delete, FindByRef, Update, Create, Delete, FindByRef, Update,
}; };
use attune_common::{
models::{key::Key, OwnerType},
rbac::{Action, AuthorizationContext, Resource},
};
use crate::auth::RequireAuth; use crate::auth::{jwt::TokenType, RequireAuth};
use crate::{ use crate::{
authz::{AuthorizationCheck, AuthorizationService},
dto::{ dto::{
common::{PaginatedResponse, PaginationParams}, common::{PaginatedResponse, PaginationParams},
key::{CreateKeyRequest, KeyQueryParams, KeyResponse, KeySummary, UpdateKeyRequest}, key::{CreateKeyRequest, KeyQueryParams, KeyResponse, KeySummary, UpdateKeyRequest},
@@ -42,7 +46,7 @@ use crate::{
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn list_keys( pub async fn list_keys(
_user: RequireAuth, user: RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Query(query): Query<KeyQueryParams>, Query(query): Query<KeyQueryParams>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -55,8 +59,33 @@ pub async fn list_keys(
}; };
let result = KeyRepository::search(&state.db, &filters).await?; let result = KeyRepository::search(&state.db, &filters).await?;
let mut rows = result.rows;
let paginated_keys: Vec<KeySummary> = result.rows.into_iter().map(KeySummary::from).collect(); if user.0.claims.token_type == TokenType::Access {
let identity_id = user
.0
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let grants = authz.effective_grants(&user.0).await?;
// Ensure the principal can read at least some key records.
let can_read_any_key = grants
.iter()
.any(|g| g.resource == Resource::Keys && g.actions.contains(&Action::Read));
if !can_read_any_key {
return Err(ApiError::Forbidden(
"Insufficient permissions: keys:read".to_string(),
));
}
rows.retain(|key| {
let ctx = key_authorization_context(identity_id, key);
AuthorizationService::is_allowed(&grants, Resource::Keys, Action::Read, &ctx)
});
}
let paginated_keys: Vec<KeySummary> = rows.into_iter().map(KeySummary::from).collect();
let pagination_params = PaginationParams { let pagination_params = PaginationParams {
page: query.page, page: query.page,
@@ -83,7 +112,7 @@ pub async fn list_keys(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_key( pub async fn get_key(
_user: RequireAuth, user: RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(key_ref): Path<String>, Path(key_ref): Path<String>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -91,24 +120,75 @@ pub async fn get_key(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?;
// Decrypt value if encrypted // For encrypted keys, track whether this caller is permitted to see the value.
if key.encrypted { // Non-Access tokens (sensor, execution) always get full access.
let encryption_key = state let can_decrypt = if user.0.claims.token_type == TokenType::Access {
.config let identity_id = user
.security .0
.encryption_key .identity_id()
.as_ref() .map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
.ok_or_else(|| { let authz = AuthorizationService::new(state.db.clone());
ApiError::InternalServerError("Encryption key not configured on server".to_string())
})?;
let decrypted_value = // Basic read check — hide behind 404 to prevent enumeration.
attune_common::crypto::decrypt(&key.value, encryption_key).map_err(|e| { authz
.authorize(
&user.0,
AuthorizationCheck {
resource: Resource::Keys,
action: Action::Read,
context: key_authorization_context(identity_id, &key),
},
)
.await
.map_err(|_| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?;
// For encrypted keys, separately check Keys::Decrypt.
// Failing this is not an error — we just return the value as null.
if key.encrypted {
authz
.authorize(
&user.0,
AuthorizationCheck {
resource: Resource::Keys,
action: Action::Decrypt,
context: key_authorization_context(identity_id, &key),
},
)
.await
.is_ok()
} else {
true
}
} else {
true
};
// Decrypt value if encrypted and caller has permission.
// If they lack Keys::Decrypt, return null rather than the ciphertext.
if key.encrypted {
if can_decrypt {
let encryption_key =
state
.config
.security
.encryption_key
.as_ref()
.ok_or_else(|| {
ApiError::InternalServerError(
"Encryption key not configured on server".to_string(),
)
})?;
let decrypted_value = attune_common::crypto::decrypt_json(&key.value, encryption_key)
.map_err(|e| {
tracing::error!("Failed to decrypt key '{}': {}", key_ref, e); tracing::error!("Failed to decrypt key '{}': {}", key_ref, e);
ApiError::InternalServerError(format!("Failed to decrypt key: {}", e)) ApiError::InternalServerError(format!("Failed to decrypt key: {}", e))
})?; })?;
key.value = decrypted_value; key.value = decrypted_value;
} else {
key.value = serde_json::Value::Null;
}
} }
let response = ApiResponse::new(KeyResponse::from(key)); let response = ApiResponse::new(KeyResponse::from(key));
@@ -130,13 +210,38 @@ pub async fn get_key(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn create_key( pub async fn create_key(
_user: RequireAuth, user: RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(request): Json<CreateKeyRequest>, Json(request): Json<CreateKeyRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Validate request // Validate request
request.validate()?; request.validate()?;
if user.0.claims.token_type == TokenType::Access {
let identity_id = user
.0
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.owner_identity_id = request.owner_identity;
ctx.owner_type = Some(request.owner_type);
ctx.owner_ref = requested_key_owner_ref(&request);
ctx.encrypted = Some(request.encrypted);
ctx.target_ref = Some(request.r#ref.clone());
authz
.authorize(
&user.0,
AuthorizationCheck {
resource: Resource::Keys,
action: Action::Create,
context: ctx,
},
)
.await?;
}
// Check if key with same ref already exists // Check if key with same ref already exists
if KeyRepository::find_by_ref(&state.db, &request.r#ref) if KeyRepository::find_by_ref(&state.db, &request.r#ref)
.await? .await?
@@ -233,11 +338,11 @@ pub async fn create_key(
) )
})?; })?;
let encrypted_value = attune_common::crypto::encrypt(&request.value, encryption_key) let encrypted_value = attune_common::crypto::encrypt_json(&request.value, encryption_key)
.map_err(|e| { .map_err(|e| {
tracing::error!("Failed to encrypt key value: {}", e); tracing::error!("Failed to encrypt key value: {}", e);
ApiError::InternalServerError(format!("Failed to encrypt value: {}", e)) ApiError::InternalServerError(format!("Failed to encrypt value: {}", e))
})?; })?;
let key_hash = attune_common::crypto::hash_encryption_key(encryption_key); let key_hash = attune_common::crypto::hash_encryption_key(encryption_key);
@@ -270,10 +375,11 @@ pub async fn create_key(
// Return decrypted value in response // Return decrypted value in response
if key.encrypted { if key.encrypted {
let encryption_key = state.config.security.encryption_key.as_ref().unwrap(); let encryption_key = state.config.security.encryption_key.as_ref().unwrap();
key.value = attune_common::crypto::decrypt(&key.value, encryption_key).map_err(|e| { key.value =
tracing::error!("Failed to decrypt newly created key: {}", e); attune_common::crypto::decrypt_json(&key.value, encryption_key).map_err(|e| {
ApiError::InternalServerError(format!("Failed to decrypt value: {}", e)) tracing::error!("Failed to decrypt newly created key: {}", e);
})?; ApiError::InternalServerError(format!("Failed to decrypt value: {}", e))
})?;
} }
let response = ApiResponse::with_message(KeyResponse::from(key), "Key created successfully"); let response = ApiResponse::with_message(KeyResponse::from(key), "Key created successfully");
@@ -298,7 +404,7 @@ pub async fn create_key(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn update_key( pub async fn update_key(
_user: RequireAuth, user: RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(key_ref): Path<String>, Path(key_ref): Path<String>,
Json(request): Json<UpdateKeyRequest>, Json(request): Json<UpdateKeyRequest>,
@@ -311,6 +417,24 @@ pub async fn update_key(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?;
if user.0.claims.token_type == TokenType::Access {
let identity_id = user
.0
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
authz
.authorize(
&user.0,
AuthorizationCheck {
resource: Resource::Keys,
action: Action::Update,
context: key_authorization_context(identity_id, &existing),
},
)
.await?;
}
// Handle value update with encryption // Handle value update with encryption
let (value, encrypted, encryption_key_hash) = if let Some(new_value) = request.value { let (value, encrypted, encryption_key_hash) = if let Some(new_value) = request.value {
let should_encrypt = request.encrypted.unwrap_or(existing.encrypted); let should_encrypt = request.encrypted.unwrap_or(existing.encrypted);
@@ -328,11 +452,11 @@ pub async fn update_key(
) )
})?; })?;
let encrypted_value = attune_common::crypto::encrypt(&new_value, encryption_key) let encrypted_value = attune_common::crypto::encrypt_json(&new_value, encryption_key)
.map_err(|e| { .map_err(|e| {
tracing::error!("Failed to encrypt key value: {}", e); tracing::error!("Failed to encrypt key value: {}", e);
ApiError::InternalServerError(format!("Failed to encrypt value: {}", e)) ApiError::InternalServerError(format!("Failed to encrypt value: {}", e))
})?; })?;
let key_hash = attune_common::crypto::hash_encryption_key(encryption_key); let key_hash = attune_common::crypto::hash_encryption_key(encryption_key);
@@ -366,7 +490,7 @@ pub async fn update_key(
ApiError::InternalServerError("Encryption key not configured on server".to_string()) ApiError::InternalServerError("Encryption key not configured on server".to_string())
})?; })?;
updated_key.value = attune_common::crypto::decrypt(&updated_key.value, encryption_key) updated_key.value = attune_common::crypto::decrypt_json(&updated_key.value, encryption_key)
.map_err(|e| { .map_err(|e| {
tracing::error!("Failed to decrypt updated key '{}': {}", key_ref, e); tracing::error!("Failed to decrypt updated key '{}': {}", key_ref, e);
ApiError::InternalServerError(format!("Failed to decrypt value: {}", e)) ApiError::InternalServerError(format!("Failed to decrypt value: {}", e))
@@ -394,7 +518,7 @@ pub async fn update_key(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn delete_key( pub async fn delete_key(
_user: RequireAuth, user: RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(key_ref): Path<String>, Path(key_ref): Path<String>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -403,6 +527,24 @@ pub async fn delete_key(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?;
if user.0.claims.token_type == TokenType::Access {
let identity_id = user
.0
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
authz
.authorize(
&user.0,
AuthorizationCheck {
resource: Resource::Keys,
action: Action::Delete,
context: key_authorization_context(identity_id, &key),
},
)
.await?;
}
// Delete the key // Delete the key
let deleted = KeyRepository::delete(&state.db, key.id).await?; let deleted = KeyRepository::delete(&state.db, key.id).await?;
@@ -424,3 +566,45 @@ pub fn routes() -> Router<Arc<AppState>> {
get(get_key).put(update_key).delete(delete_key), get(get_key).put(update_key).delete(delete_key),
) )
} }
fn key_authorization_context(identity_id: i64, key: &Key) -> AuthorizationContext {
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_id = Some(key.id);
ctx.target_ref = Some(key.r#ref.clone());
ctx.owner_identity_id = key.owner_identity;
ctx.owner_type = Some(key.owner_type);
ctx.owner_ref = key_owner_ref(
key.owner_type,
key.owner.as_deref(),
key.owner_pack_ref.as_deref(),
key.owner_action_ref.as_deref(),
key.owner_sensor_ref.as_deref(),
);
ctx.encrypted = Some(key.encrypted);
ctx
}
fn requested_key_owner_ref(request: &CreateKeyRequest) -> Option<String> {
key_owner_ref(
request.owner_type,
request.owner.as_deref(),
request.owner_pack_ref.as_deref(),
request.owner_action_ref.as_deref(),
request.owner_sensor_ref.as_deref(),
)
}
fn key_owner_ref(
owner_type: OwnerType,
owner: Option<&str>,
owner_pack_ref: Option<&str>,
owner_action_ref: Option<&str>,
owner_sensor_ref: Option<&str>,
) -> Option<String> {
match owner_type {
OwnerType::Pack => owner_pack_ref.map(str::to_string),
OwnerType::Action => owner_action_ref.map(str::to_string),
OwnerType::Sensor => owner_sensor_ref.map(str::to_string),
_ => owner.map(str::to_string),
}
}

View File

@@ -1,6 +1,7 @@
//! API route modules //! API route modules
pub mod actions; pub mod actions;
pub mod agent;
pub mod analytics; pub mod analytics;
pub mod artifacts; pub mod artifacts;
pub mod auth; pub mod auth;
@@ -11,12 +12,15 @@ pub mod history;
pub mod inquiries; pub mod inquiries;
pub mod keys; pub mod keys;
pub mod packs; pub mod packs;
pub mod permissions;
pub mod rules; pub mod rules;
pub mod runtimes;
pub mod triggers; pub mod triggers;
pub mod webhooks; pub mod webhooks;
pub mod workflows; pub mod workflows;
pub use actions::routes as action_routes; pub use actions::routes as action_routes;
pub use agent::routes as agent_routes;
pub use analytics::routes as analytics_routes; pub use analytics::routes as analytics_routes;
pub use artifacts::routes as artifact_routes; pub use artifacts::routes as artifact_routes;
pub use auth::routes as auth_routes; pub use auth::routes as auth_routes;
@@ -27,7 +31,9 @@ pub use history::routes as history_routes;
pub use inquiries::routes as inquiry_routes; pub use inquiries::routes as inquiry_routes;
pub use keys::routes as key_routes; pub use keys::routes as key_routes;
pub use packs::routes as pack_routes; pub use packs::routes as pack_routes;
pub use permissions::routes as permission_routes;
pub use rules::routes as rule_routes; pub use rules::routes as rule_routes;
pub use runtimes::routes as runtime_routes;
pub use triggers::routes as trigger_routes; pub use triggers::routes as trigger_routes;
pub use webhooks::routes as webhook_routes; pub use webhooks::routes as webhook_routes;
pub use workflows::routes as workflow_routes; pub use workflows::routes as workflow_routes;

View File

@@ -13,22 +13,26 @@ use validator::Validate;
use attune_common::models::pack_test::PackTestResult; use attune_common::models::pack_test::PackTestResult;
use attune_common::mq::{MessageEnvelope, MessageType, PackRegisteredPayload}; use attune_common::mq::{MessageEnvelope, MessageType, PackRegisteredPayload};
use attune_common::rbac::{Action, AuthorizationContext, Resource};
use attune_common::repositories::{ use attune_common::repositories::{
pack::{CreatePackInput, UpdatePackInput}, pack::{CreatePackInput, UpdatePackInput},
Create, Delete, FindById, FindByRef, PackRepository, PackTestRepository, Pagination, Update, Create, Delete, FindById, FindByRef, PackRepository, PackTestRepository, Pagination, Patch,
Update,
}; };
use attune_common::workflow::{PackWorkflowService, PackWorkflowServiceConfig}; use attune_common::workflow::{PackWorkflowService, PackWorkflowServiceConfig};
use crate::{ use crate::{
auth::middleware::RequireAuth, auth::middleware::RequireAuth,
authz::{AuthorizationCheck, AuthorizationService},
dto::{ dto::{
common::{PaginatedResponse, PaginationParams}, common::{PaginatedResponse, PaginationParams},
pack::{ pack::{
BuildPackEnvsRequest, BuildPackEnvsResponse, CreatePackRequest, DownloadPacksRequest, BuildPackEnvsRequest, BuildPackEnvsResponse, CreatePackRequest, DownloadPacksRequest,
DownloadPacksResponse, GetPackDependenciesRequest, GetPackDependenciesResponse, DownloadPacksResponse, GetPackDependenciesRequest, GetPackDependenciesResponse,
InstallPackRequest, PackInstallResponse, PackResponse, PackSummary, InstallPackRequest, PackDescriptionPatch, PackInstallResponse, PackResponse,
PackWorkflowSyncResponse, PackWorkflowValidationResponse, RegisterPackRequest, PackSummary, PackWorkflowSyncResponse, PackWorkflowValidationResponse,
RegisterPacksRequest, RegisterPacksResponse, UpdatePackRequest, WorkflowSyncResult, RegisterPackRequest, RegisterPacksRequest, RegisterPacksResponse, UpdatePackRequest,
WorkflowSyncResult,
}, },
ApiResponse, SuccessResponse, ApiResponse, SuccessResponse,
}, },
@@ -115,7 +119,7 @@ pub async fn get_pack(
)] )]
pub async fn create_pack( pub async fn create_pack(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
Json(request): Json<CreatePackRequest>, Json(request): Json<CreatePackRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Validate request // Validate request
@@ -129,6 +133,25 @@ pub async fn create_pack(
))); )));
} }
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_ref = Some(request.r#ref.clone());
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Packs,
action: Action::Create,
context: ctx,
},
)
.await?;
}
// Create pack input // Create pack input
let pack_input = CreatePackInput { let pack_input = CreatePackInput {
r#ref: request.r#ref, r#ref: request.r#ref,
@@ -202,7 +225,7 @@ pub async fn create_pack(
)] )]
pub async fn update_pack( pub async fn update_pack(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
Path(pack_ref): Path<String>, Path(pack_ref): Path<String>,
Json(request): Json<UpdatePackRequest>, Json(request): Json<UpdatePackRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -214,10 +237,33 @@ pub async fn update_pack(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", pack_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", pack_ref)))?;
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_id = Some(existing_pack.id);
ctx.target_ref = Some(existing_pack.r#ref.clone());
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Packs,
action: Action::Update,
context: ctx,
},
)
.await?;
}
// Create update input // Create update input
let update_input = UpdatePackInput { let update_input = UpdatePackInput {
label: request.label, label: request.label,
description: request.description, description: request.description.map(|patch| match patch {
PackDescriptionPatch::Set(value) => Patch::Set(value),
PackDescriptionPatch::Clear => Patch::Clear,
}),
version: request.version, version: request.version,
conf_schema: request.conf_schema, conf_schema: request.conf_schema,
config: request.config, config: request.config,
@@ -284,7 +330,7 @@ pub async fn update_pack(
)] )]
pub async fn delete_pack( pub async fn delete_pack(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
Path(pack_ref): Path<String>, Path(pack_ref): Path<String>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Check if pack exists // Check if pack exists
@@ -292,6 +338,26 @@ pub async fn delete_pack(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", pack_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", pack_ref)))?;
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_id = Some(pack.id);
ctx.target_ref = Some(pack.r#ref.clone());
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Packs,
action: Action::Delete,
context: ctx,
},
)
.await?;
}
// Delete the pack from the database (cascades to actions, triggers, sensors, rules, etc. // Delete the pack from the database (cascades to actions, triggers, sensors, rules, etc.
// Foreign keys on execution, event, enforcement, and rule tables use ON DELETE SET NULL // Foreign keys on execution, event, enforcement, and rule tables use ON DELETE SET NULL
// so historical records are preserved with their text ref fields intact.) // so historical records are preserved with their text ref fields intact.)
@@ -475,6 +541,23 @@ pub async fn upload_pack(
const MAX_PACK_SIZE: usize = 100 * 1024 * 1024; // 100 MB const MAX_PACK_SIZE: usize = 100 * 1024 * 1024; // 100 MB
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Packs,
action: Action::Create,
context: AuthorizationContext::new(identity_id),
},
)
.await?;
}
let mut pack_bytes: Option<Vec<u8>> = None; let mut pack_bytes: Option<Vec<u8>> = None;
let mut force = false; let mut force = false;
let mut skip_tests = false; let mut skip_tests = false;
@@ -649,6 +732,23 @@ pub async fn register_pack(
// Validate request // Validate request
request.validate()?; request.validate()?;
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Packs,
action: Action::Create,
context: AuthorizationContext::new(identity_id),
},
)
.await?;
}
// Call internal registration logic // Call internal registration logic
let pack_id = register_pack_internal( let pack_id = register_pack_internal(
state.clone(), state.clone(),
@@ -781,7 +881,10 @@ async fn register_pack_internal(
// Update existing pack in place — preserves pack ID and all child entity IDs // Update existing pack in place — preserves pack ID and all child entity IDs
let update_input = UpdatePackInput { let update_input = UpdatePackInput {
label: Some(label), label: Some(label),
description: Some(description.unwrap_or_default()), description: Some(match description {
Some(value) => Patch::Set(value),
None => Patch::Clear,
}),
version: Some(version.clone()), version: Some(version.clone()),
conf_schema: Some(conf_schema), conf_schema: Some(conf_schema),
config: None, // preserve user-set config config: None, // preserve user-set config
@@ -1207,6 +1310,23 @@ pub async fn install_pack(
tracing::info!("Installing pack from source: {}", request.source); tracing::info!("Installing pack from source: {}", request.source);
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Packs,
action: Action::Create,
context: AuthorizationContext::new(identity_id),
},
)
.await?;
}
// Get user ID early to avoid borrow issues // Get user ID early to avoid borrow issues
let user_id = user.identity_id().ok(); let user_id = user.identity_id().ok();
let user_sub = user.claims.sub.clone(); let user_sub = user.claims.sub.clone();
@@ -2247,6 +2367,23 @@ pub async fn register_packs_batch(
RequireAuth(user): RequireAuth, RequireAuth(user): RequireAuth,
Json(request): Json<RegisterPacksRequest>, Json(request): Json<RegisterPacksRequest>,
) -> ApiResult<Json<ApiResponse<RegisterPacksResponse>>> { ) -> ApiResult<Json<ApiResponse<RegisterPacksResponse>>> {
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Packs,
action: Action::Create,
context: AuthorizationContext::new(identity_id),
},
)
.await?;
}
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let mut registered = Vec::new(); let mut registered = Vec::new();
let mut failed = Vec::new(); let mut failed = Vec::new();

View File

@@ -0,0 +1,877 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, get, post},
Json, Router,
};
use std::sync::Arc;
use validator::Validate;
use attune_common::{
models::identity::{Identity, IdentityRoleAssignment},
rbac::{Action, AuthorizationContext, Resource},
repositories::{
identity::{
CreateIdentityInput, CreateIdentityRoleAssignmentInput,
CreatePermissionAssignmentInput, CreatePermissionSetRoleAssignmentInput,
IdentityRepository, IdentityRoleAssignmentRepository, PermissionAssignmentRepository,
PermissionSetRepository, PermissionSetRoleAssignmentRepository, UpdateIdentityInput,
},
Create, Delete, FindById, FindByRef, List, Update,
},
};
use crate::{
auth::hash_password,
auth::middleware::RequireAuth,
authz::{AuthorizationCheck, AuthorizationService},
dto::{
common::{PaginatedResponse, PaginationParams},
ApiResponse, CreateIdentityRequest, CreateIdentityRoleAssignmentRequest,
CreatePermissionAssignmentRequest, CreatePermissionSetRoleAssignmentRequest,
IdentityResponse, IdentityRoleAssignmentResponse, IdentitySummary,
PermissionAssignmentResponse, PermissionSetQueryParams,
PermissionSetRoleAssignmentResponse, PermissionSetSummary, SuccessResponse,
UpdateIdentityRequest,
},
middleware::{ApiError, ApiResult},
state::AppState,
};
#[utoipa::path(
get,
path = "/api/v1/identities",
tag = "permissions",
params(PaginationParams),
responses(
(status = 200, description = "List identities", body = PaginatedResponse<IdentitySummary>)
),
security(("bearer_auth" = []))
)]
pub async fn list_identities(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Query(query): Query<PaginationParams>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Identities, Action::Read).await?;
let identities = IdentityRepository::list(&state.db).await?;
let total = identities.len() as u64;
let start = query.offset() as usize;
let end = (start + query.limit() as usize).min(identities.len());
let page_items = if start >= identities.len() {
Vec::new()
} else {
identities[start..end].to_vec()
};
let mut summaries = Vec::with_capacity(page_items.len());
for identity in page_items {
let role_assignments =
IdentityRoleAssignmentRepository::find_by_identity(&state.db, identity.id).await?;
let roles = role_assignments.into_iter().map(|ra| ra.role).collect();
let mut summary = IdentitySummary::from(identity);
summary.roles = roles;
summaries.push(summary);
}
Ok((
StatusCode::OK,
Json(PaginatedResponse::new(summaries, &query, total)),
))
}
#[utoipa::path(
get,
path = "/api/v1/identities/{id}",
tag = "permissions",
params(
("id" = i64, Path, description = "Identity ID")
),
responses(
(status = 200, description = "Identity details", body = inline(ApiResponse<IdentityResponse>)),
(status = 404, description = "Identity not found")
),
security(("bearer_auth" = []))
)]
pub async fn get_identity(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(identity_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Identities, Action::Read).await?;
let identity = IdentityRepository::find_by_id(&state.db, identity_id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Identity '{}' not found", identity_id)))?;
let roles = IdentityRoleAssignmentRepository::find_by_identity(&state.db, identity_id).await?;
let assignments =
PermissionAssignmentRepository::find_by_identity(&state.db, identity_id).await?;
let permission_sets = PermissionSetRepository::find_by_identity(&state.db, identity_id).await?;
let permission_set_refs = permission_sets
.into_iter()
.map(|ps| (ps.id, ps.r#ref))
.collect::<std::collections::HashMap<_, _>>();
Ok((
StatusCode::OK,
Json(ApiResponse::new(IdentityResponse {
id: identity.id,
login: identity.login,
display_name: identity.display_name,
frozen: identity.frozen,
attributes: identity.attributes,
roles: roles
.into_iter()
.map(IdentityRoleAssignmentResponse::from)
.collect(),
direct_permissions: assignments
.into_iter()
.filter_map(|assignment| {
permission_set_refs.get(&assignment.permset).cloned().map(
|permission_set_ref| PermissionAssignmentResponse {
id: assignment.id,
identity_id: assignment.identity,
permission_set_id: assignment.permset,
permission_set_ref,
created: assignment.created,
},
)
})
.collect(),
})),
))
}
#[utoipa::path(
post,
path = "/api/v1/identities",
tag = "permissions",
request_body = CreateIdentityRequest,
responses(
(status = 201, description = "Identity created", body = inline(ApiResponse<IdentityResponse>)),
(status = 409, description = "Identity already exists")
),
security(("bearer_auth" = []))
)]
pub async fn create_identity(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Json(request): Json<CreateIdentityRequest>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Identities, Action::Create).await?;
request.validate()?;
let password_hash = match request.password {
Some(password) => Some(hash_password(&password)?),
None => None,
};
let identity = IdentityRepository::create(
&state.db,
CreateIdentityInput {
login: request.login,
display_name: request.display_name,
password_hash,
attributes: request.attributes,
},
)
.await?;
Ok((
StatusCode::CREATED,
Json(ApiResponse::new(IdentityResponse::from(identity))),
))
}
#[utoipa::path(
put,
path = "/api/v1/identities/{id}",
tag = "permissions",
params(
("id" = i64, Path, description = "Identity ID")
),
request_body = UpdateIdentityRequest,
responses(
(status = 200, description = "Identity updated", body = inline(ApiResponse<IdentityResponse>)),
(status = 404, description = "Identity not found")
),
security(("bearer_auth" = []))
)]
pub async fn update_identity(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(identity_id): Path<i64>,
Json(request): Json<UpdateIdentityRequest>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Identities, Action::Update).await?;
IdentityRepository::find_by_id(&state.db, identity_id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Identity '{}' not found", identity_id)))?;
let password_hash = match request.password {
Some(password) => Some(hash_password(&password)?),
None => None,
};
let identity = IdentityRepository::update(
&state.db,
identity_id,
UpdateIdentityInput {
display_name: request.display_name,
password_hash,
attributes: request.attributes,
frozen: request.frozen,
},
)
.await?;
Ok((
StatusCode::OK,
Json(ApiResponse::new(IdentityResponse::from(identity))),
))
}
#[utoipa::path(
delete,
path = "/api/v1/identities/{id}",
tag = "permissions",
params(
("id" = i64, Path, description = "Identity ID")
),
responses(
(status = 200, description = "Identity deleted", body = inline(ApiResponse<SuccessResponse>)),
(status = 404, description = "Identity not found")
),
security(("bearer_auth" = []))
)]
pub async fn delete_identity(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(identity_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Identities, Action::Delete).await?;
let caller_identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
if caller_identity_id == identity_id {
return Err(ApiError::BadRequest(
"Refusing to delete the currently authenticated identity".to_string(),
));
}
let deleted = IdentityRepository::delete(&state.db, identity_id).await?;
if !deleted {
return Err(ApiError::NotFound(format!(
"Identity '{}' not found",
identity_id
)));
}
Ok((
StatusCode::OK,
Json(ApiResponse::new(SuccessResponse::new(
"Identity deleted successfully",
))),
))
}
#[utoipa::path(
get,
path = "/api/v1/permissions/sets",
tag = "permissions",
params(PermissionSetQueryParams),
responses(
(status = 200, description = "List permission sets", body = Vec<PermissionSetSummary>)
),
security(("bearer_auth" = []))
)]
pub async fn list_permission_sets(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Query(query): Query<PermissionSetQueryParams>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Permissions, Action::Read).await?;
let mut permission_sets = PermissionSetRepository::list(&state.db).await?;
if let Some(pack_ref) = &query.pack_ref {
permission_sets.retain(|ps| ps.pack_ref.as_deref() == Some(pack_ref.as_str()));
}
let mut response = Vec::with_capacity(permission_sets.len());
for permission_set in permission_sets {
let permission_set_ref = permission_set.r#ref.clone();
let roles = PermissionSetRoleAssignmentRepository::find_by_permission_set(
&state.db,
permission_set.id,
)
.await?;
response.push(PermissionSetSummary {
id: permission_set.id,
r#ref: permission_set.r#ref,
pack_ref: permission_set.pack_ref,
label: permission_set.label,
description: permission_set.description,
grants: permission_set.grants,
roles: roles
.into_iter()
.map(|assignment| PermissionSetRoleAssignmentResponse {
id: assignment.id,
permission_set_id: assignment.permset,
permission_set_ref: Some(permission_set_ref.clone()),
role: assignment.role,
created: assignment.created,
})
.collect(),
});
}
Ok((StatusCode::OK, Json(response)))
}
#[utoipa::path(
get,
path = "/api/v1/identities/{id}/permissions",
tag = "permissions",
params(
("id" = i64, Path, description = "Identity ID")
),
responses(
(status = 200, description = "List permission assignments for an identity", body = Vec<PermissionAssignmentResponse>),
(status = 404, description = "Identity not found")
),
security(("bearer_auth" = []))
)]
pub async fn list_identity_permissions(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(identity_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Permissions, Action::Read).await?;
IdentityRepository::find_by_id(&state.db, identity_id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Identity '{}' not found", identity_id)))?;
let assignments =
PermissionAssignmentRepository::find_by_identity(&state.db, identity_id).await?;
let permission_sets = PermissionSetRepository::find_by_identity(&state.db, identity_id).await?;
let permission_set_refs = permission_sets
.into_iter()
.map(|ps| (ps.id, ps.r#ref))
.collect::<std::collections::HashMap<_, _>>();
let response: Vec<PermissionAssignmentResponse> = assignments
.into_iter()
.filter_map(|assignment| {
permission_set_refs
.get(&assignment.permset)
.cloned()
.map(|permission_set_ref| PermissionAssignmentResponse {
id: assignment.id,
identity_id: assignment.identity,
permission_set_id: assignment.permset,
permission_set_ref,
created: assignment.created,
})
})
.collect();
Ok((StatusCode::OK, Json(response)))
}
#[utoipa::path(
post,
path = "/api/v1/permissions/assignments",
tag = "permissions",
request_body = CreatePermissionAssignmentRequest,
responses(
(status = 201, description = "Permission assignment created", body = inline(ApiResponse<PermissionAssignmentResponse>)),
(status = 404, description = "Identity or permission set not found"),
(status = 409, description = "Assignment already exists")
),
security(("bearer_auth" = []))
)]
pub async fn create_permission_assignment(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Json(request): Json<CreatePermissionAssignmentRequest>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
let identity = resolve_identity(&state, &request).await?;
let permission_set =
PermissionSetRepository::find_by_ref(&state.db, &request.permission_set_ref)
.await?
.ok_or_else(|| {
ApiError::NotFound(format!(
"Permission set '{}' not found",
request.permission_set_ref
))
})?;
let assignment = PermissionAssignmentRepository::create(
&state.db,
CreatePermissionAssignmentInput {
identity: identity.id,
permset: permission_set.id,
},
)
.await?;
let response = PermissionAssignmentResponse {
id: assignment.id,
identity_id: assignment.identity,
permission_set_id: assignment.permset,
permission_set_ref: permission_set.r#ref,
created: assignment.created,
};
Ok((StatusCode::CREATED, Json(ApiResponse::new(response))))
}
#[utoipa::path(
delete,
path = "/api/v1/permissions/assignments/{id}",
tag = "permissions",
params(
("id" = i64, Path, description = "Permission assignment ID")
),
responses(
(status = 200, description = "Permission assignment deleted", body = inline(ApiResponse<SuccessResponse>)),
(status = 404, description = "Assignment not found")
),
security(("bearer_auth" = []))
)]
pub async fn delete_permission_assignment(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(assignment_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
let existing = PermissionAssignmentRepository::find_by_id(&state.db, assignment_id)
.await?
.ok_or_else(|| {
ApiError::NotFound(format!(
"Permission assignment '{}' not found",
assignment_id
))
})?;
let deleted = PermissionAssignmentRepository::delete(&state.db, existing.id).await?;
if !deleted {
return Err(ApiError::NotFound(format!(
"Permission assignment '{}' not found",
assignment_id
)));
}
Ok((
StatusCode::OK,
Json(ApiResponse::new(SuccessResponse::new(
"Permission assignment deleted successfully",
))),
))
}
#[utoipa::path(
post,
path = "/api/v1/identities/{id}/roles",
tag = "permissions",
params(
("id" = i64, Path, description = "Identity ID")
),
request_body = CreateIdentityRoleAssignmentRequest,
responses(
(status = 201, description = "Identity role assignment created", body = inline(ApiResponse<IdentityRoleAssignmentResponse>)),
(status = 404, description = "Identity not found")
),
security(("bearer_auth" = []))
)]
pub async fn create_identity_role_assignment(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(identity_id): Path<i64>,
Json(request): Json<CreateIdentityRoleAssignmentRequest>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
request.validate()?;
IdentityRepository::find_by_id(&state.db, identity_id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Identity '{}' not found", identity_id)))?;
let assignment = IdentityRoleAssignmentRepository::create(
&state.db,
CreateIdentityRoleAssignmentInput {
identity: identity_id,
role: request.role,
source: "manual".to_string(),
managed: false,
},
)
.await?;
Ok((
StatusCode::CREATED,
Json(ApiResponse::new(IdentityRoleAssignmentResponse::from(
assignment,
))),
))
}
#[utoipa::path(
delete,
path = "/api/v1/identities/roles/{id}",
tag = "permissions",
params(
("id" = i64, Path, description = "Identity role assignment ID")
),
responses(
(status = 200, description = "Identity role assignment deleted", body = inline(ApiResponse<SuccessResponse>)),
(status = 404, description = "Identity role assignment not found")
),
security(("bearer_auth" = []))
)]
pub async fn delete_identity_role_assignment(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(assignment_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
let assignment = IdentityRoleAssignmentRepository::find_by_id(&state.db, assignment_id)
.await?
.ok_or_else(|| {
ApiError::NotFound(format!(
"Identity role assignment '{}' not found",
assignment_id
))
})?;
if assignment.managed {
return Err(ApiError::BadRequest(
"Managed role assignments must be updated through the identity provider sync"
.to_string(),
));
}
IdentityRoleAssignmentRepository::delete(&state.db, assignment_id).await?;
Ok((
StatusCode::OK,
Json(ApiResponse::new(SuccessResponse::new(
"Identity role assignment deleted successfully",
))),
))
}
#[utoipa::path(
post,
path = "/api/v1/permissions/sets/{id}/roles",
tag = "permissions",
params(
("id" = i64, Path, description = "Permission set ID")
),
request_body = CreatePermissionSetRoleAssignmentRequest,
responses(
(status = 201, description = "Permission set role assignment created", body = inline(ApiResponse<PermissionSetRoleAssignmentResponse>)),
(status = 404, description = "Permission set not found")
),
security(("bearer_auth" = []))
)]
pub async fn create_permission_set_role_assignment(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(permission_set_id): Path<i64>,
Json(request): Json<CreatePermissionSetRoleAssignmentRequest>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
request.validate()?;
let permission_set = PermissionSetRepository::find_by_id(&state.db, permission_set_id)
.await?
.ok_or_else(|| {
ApiError::NotFound(format!("Permission set '{}' not found", permission_set_id))
})?;
let assignment = PermissionSetRoleAssignmentRepository::create(
&state.db,
CreatePermissionSetRoleAssignmentInput {
permset: permission_set_id,
role: request.role,
},
)
.await?;
Ok((
StatusCode::CREATED,
Json(ApiResponse::new(PermissionSetRoleAssignmentResponse {
id: assignment.id,
permission_set_id: assignment.permset,
permission_set_ref: Some(permission_set.r#ref),
role: assignment.role,
created: assignment.created,
})),
))
}
#[utoipa::path(
delete,
path = "/api/v1/permissions/sets/roles/{id}",
tag = "permissions",
params(
("id" = i64, Path, description = "Permission set role assignment ID")
),
responses(
(status = 200, description = "Permission set role assignment deleted", body = inline(ApiResponse<SuccessResponse>)),
(status = 404, description = "Permission set role assignment not found")
),
security(("bearer_auth" = []))
)]
pub async fn delete_permission_set_role_assignment(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(assignment_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
PermissionSetRoleAssignmentRepository::find_by_id(&state.db, assignment_id)
.await?
.ok_or_else(|| {
ApiError::NotFound(format!(
"Permission set role assignment '{}' not found",
assignment_id
))
})?;
PermissionSetRoleAssignmentRepository::delete(&state.db, assignment_id).await?;
Ok((
StatusCode::OK,
Json(ApiResponse::new(SuccessResponse::new(
"Permission set role assignment deleted successfully",
))),
))
}
#[utoipa::path(
post,
path = "/api/v1/identities/{id}/freeze",
tag = "permissions",
params(
("id" = i64, Path, description = "Identity ID")
),
responses(
(status = 200, description = "Identity frozen", body = inline(ApiResponse<SuccessResponse>)),
(status = 404, description = "Identity not found")
),
security(("bearer_auth" = []))
)]
pub async fn freeze_identity(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(identity_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
set_identity_frozen(&state, &user, identity_id, true).await
}
#[utoipa::path(
post,
path = "/api/v1/identities/{id}/unfreeze",
tag = "permissions",
params(
("id" = i64, Path, description = "Identity ID")
),
responses(
(status = 200, description = "Identity unfrozen", body = inline(ApiResponse<SuccessResponse>)),
(status = 404, description = "Identity not found")
),
security(("bearer_auth" = []))
)]
pub async fn unfreeze_identity(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(identity_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
set_identity_frozen(&state, &user, identity_id, false).await
}
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/identities", get(list_identities).post(create_identity))
.route(
"/identities/{id}",
get(get_identity)
.put(update_identity)
.delete(delete_identity),
)
.route(
"/identities/{id}/roles",
post(create_identity_role_assignment),
)
.route(
"/identities/{id}/permissions",
get(list_identity_permissions),
)
.route("/identities/{id}/freeze", post(freeze_identity))
.route("/identities/{id}/unfreeze", post(unfreeze_identity))
.route(
"/identities/roles/{id}",
delete(delete_identity_role_assignment),
)
.route("/permissions/sets", get(list_permission_sets))
.route(
"/permissions/sets/{id}/roles",
post(create_permission_set_role_assignment),
)
.route(
"/permissions/sets/roles/{id}",
delete(delete_permission_set_role_assignment),
)
.route(
"/permissions/assignments",
post(create_permission_assignment),
)
.route(
"/permissions/assignments/{id}",
delete(delete_permission_assignment),
)
}
async fn authorize_permissions(
state: &Arc<AppState>,
user: &crate::auth::middleware::AuthenticatedUser,
resource: Resource,
action: Action,
) -> ApiResult<()> {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
authz
.authorize(
user,
AuthorizationCheck {
resource,
action,
context: AuthorizationContext::new(identity_id),
},
)
.await
}
async fn resolve_identity(
state: &Arc<AppState>,
request: &CreatePermissionAssignmentRequest,
) -> ApiResult<Identity> {
match (request.identity_id, request.identity_login.as_deref()) {
(Some(identity_id), None) => IdentityRepository::find_by_id(&state.db, identity_id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Identity '{}' not found", identity_id))),
(None, Some(identity_login)) => {
IdentityRepository::find_by_login(&state.db, identity_login)
.await?
.ok_or_else(|| {
ApiError::NotFound(format!("Identity '{}' not found", identity_login))
})
}
(Some(_), Some(_)) => Err(ApiError::BadRequest(
"Provide either identity_id or identity_login, not both".to_string(),
)),
(None, None) => Err(ApiError::BadRequest(
"Either identity_id or identity_login is required".to_string(),
)),
}
}
impl From<Identity> for IdentitySummary {
fn from(value: Identity) -> Self {
Self {
id: value.id,
login: value.login,
display_name: value.display_name,
frozen: value.frozen,
attributes: value.attributes,
roles: Vec::new(),
}
}
}
impl From<IdentityRoleAssignment> for IdentityRoleAssignmentResponse {
fn from(value: IdentityRoleAssignment) -> Self {
Self {
id: value.id,
identity_id: value.identity,
role: value.role,
source: value.source,
managed: value.managed,
created: value.created,
updated: value.updated,
}
}
}
impl From<Identity> for IdentityResponse {
fn from(value: Identity) -> Self {
Self {
id: value.id,
login: value.login,
display_name: value.display_name,
frozen: value.frozen,
attributes: value.attributes,
roles: Vec::new(),
direct_permissions: Vec::new(),
}
}
}
async fn set_identity_frozen(
state: &Arc<AppState>,
user: &crate::auth::middleware::AuthenticatedUser,
identity_id: i64,
frozen: bool,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(state, user, Resource::Identities, Action::Update).await?;
let caller_identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
if caller_identity_id == identity_id && frozen {
return Err(ApiError::BadRequest(
"Refusing to freeze the currently authenticated identity".to_string(),
));
}
IdentityRepository::find_by_id(&state.db, identity_id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Identity '{}' not found", identity_id)))?;
IdentityRepository::update(
&state.db,
identity_id,
UpdateIdentityInput {
display_name: None,
password_hash: None,
attributes: None,
frozen: Some(frozen),
},
)
.await?;
let message = if frozen {
"Identity frozen successfully"
} else {
"Identity unfrozen successfully"
};
Ok((
StatusCode::OK,
Json(ApiResponse::new(SuccessResponse::new(message))),
))
}

View File

@@ -14,16 +14,18 @@ use validator::Validate;
use attune_common::mq::{ use attune_common::mq::{
MessageEnvelope, MessageType, RuleCreatedPayload, RuleDisabledPayload, RuleEnabledPayload, MessageEnvelope, MessageType, RuleCreatedPayload, RuleDisabledPayload, RuleEnabledPayload,
}; };
use attune_common::rbac::{Action, AuthorizationContext, Resource};
use attune_common::repositories::{ use attune_common::repositories::{
action::ActionRepository, action::ActionRepository,
pack::PackRepository, pack::PackRepository,
rule::{CreateRuleInput, RuleRepository, RuleSearchFilters, UpdateRuleInput}, rule::{CreateRuleInput, RuleRepository, RuleSearchFilters, UpdateRuleInput},
trigger::TriggerRepository, trigger::TriggerRepository,
Create, Delete, FindByRef, Update, Create, Delete, FindByRef, Patch, Update,
}; };
use crate::{ use crate::{
auth::middleware::RequireAuth, auth::middleware::RequireAuth,
authz::{AuthorizationCheck, AuthorizationService},
dto::{ dto::{
common::{PaginatedResponse, PaginationParams}, common::{PaginatedResponse, PaginationParams},
rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest}, rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest},
@@ -283,7 +285,7 @@ pub async fn get_rule(
)] )]
pub async fn create_rule( pub async fn create_rule(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
Json(request): Json<CreateRuleRequest>, Json(request): Json<CreateRuleRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Validate request // Validate request
@@ -317,6 +319,26 @@ pub async fn create_rule(
ApiError::NotFound(format!("Trigger '{}' not found", request.trigger_ref)) ApiError::NotFound(format!("Trigger '{}' not found", request.trigger_ref))
})?; })?;
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.pack_ref = Some(pack.r#ref.clone());
ctx.target_ref = Some(request.r#ref.clone());
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Rules,
action: Action::Create,
context: ctx,
},
)
.await?;
}
// Validate trigger parameters against schema // Validate trigger parameters against schema
validate_trigger_params(&trigger, &request.trigger_params)?; validate_trigger_params(&trigger, &request.trigger_params)?;
@@ -392,7 +414,7 @@ pub async fn create_rule(
)] )]
pub async fn update_rule( pub async fn update_rule(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
Path(rule_ref): Path<String>, Path(rule_ref): Path<String>,
Json(request): Json<UpdateRuleRequest>, Json(request): Json<UpdateRuleRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -404,6 +426,27 @@ pub async fn update_rule(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Rule '{}' not found", rule_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Rule '{}' not found", rule_ref)))?;
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_id = Some(existing_rule.id);
ctx.target_ref = Some(existing_rule.r#ref.clone());
ctx.pack_ref = Some(existing_rule.pack_ref.clone());
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Rules,
action: Action::Update,
context: ctx,
},
)
.await?;
}
// If action parameters are being updated, validate against the action's schema // If action parameters are being updated, validate against the action's schema
if let Some(ref action_params) = request.action_params { if let Some(ref action_params) = request.action_params {
let action = ActionRepository::find_by_ref(&state.db, &existing_rule.action_ref) let action = ActionRepository::find_by_ref(&state.db, &existing_rule.action_ref)
@@ -431,7 +474,7 @@ pub async fn update_rule(
// Create update input // Create update input
let update_input = UpdateRuleInput { let update_input = UpdateRuleInput {
label: request.label, label: request.label,
description: request.description, description: request.description.map(Patch::Set),
conditions: request.conditions, conditions: request.conditions,
action_params: request.action_params, action_params: request.action_params,
trigger_params: request.trigger_params, trigger_params: request.trigger_params,
@@ -489,7 +532,7 @@ pub async fn update_rule(
)] )]
pub async fn delete_rule( pub async fn delete_rule(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
Path(rule_ref): Path<String>, Path(rule_ref): Path<String>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Check if rule exists // Check if rule exists
@@ -497,6 +540,27 @@ pub async fn delete_rule(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Rule '{}' not found", rule_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Rule '{}' not found", rule_ref)))?;
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_id = Some(rule.id);
ctx.target_ref = Some(rule.r#ref.clone());
ctx.pack_ref = Some(rule.pack_ref.clone());
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Rules,
action: Action::Delete,
context: ctx,
},
)
.await?;
}
// Delete the rule // Delete the rule
let deleted = RuleRepository::delete(&state.db, rule.id).await?; let deleted = RuleRepository::delete(&state.db, rule.id).await?;

View File

@@ -0,0 +1,307 @@
//! Runtime management API routes
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use std::sync::Arc;
use validator::Validate;
use attune_common::repositories::{
pack::PackRepository,
runtime::{CreateRuntimeInput, RuntimeRepository, UpdateRuntimeInput},
Create, Delete, FindByRef, List, Patch, Update,
};
use crate::{
auth::middleware::RequireAuth,
dto::{
common::{PaginatedResponse, PaginationParams},
runtime::{
CreateRuntimeRequest, NullableJsonPatch, NullableStringPatch, RuntimeResponse,
RuntimeSummary, UpdateRuntimeRequest,
},
ApiResponse, SuccessResponse,
},
middleware::{ApiError, ApiResult},
state::AppState,
};
#[utoipa::path(
get,
path = "/api/v1/runtimes",
tag = "runtimes",
params(PaginationParams),
responses(
(status = 200, description = "List of runtimes", body = PaginatedResponse<RuntimeSummary>)
),
security(("bearer_auth" = []))
)]
pub async fn list_runtimes(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<impl IntoResponse> {
let all_runtimes = RuntimeRepository::list(&state.db).await?;
let total = all_runtimes.len() as u64;
let rows: Vec<_> = all_runtimes
.into_iter()
.skip(pagination.offset() as usize)
.take(pagination.limit() as usize)
.collect();
let response = PaginatedResponse::new(
rows.into_iter().map(RuntimeSummary::from).collect(),
&pagination,
total,
);
Ok((StatusCode::OK, Json(response)))
}
#[utoipa::path(
get,
path = "/api/v1/packs/{pack_ref}/runtimes",
tag = "runtimes",
params(
("pack_ref" = String, Path, description = "Pack reference identifier"),
PaginationParams
),
responses(
(status = 200, description = "List of runtimes for a pack", body = PaginatedResponse<RuntimeSummary>),
(status = 404, description = "Pack not found")
),
security(("bearer_auth" = []))
)]
pub async fn list_runtimes_by_pack(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
Path(pack_ref): Path<String>,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<impl IntoResponse> {
let pack = PackRepository::find_by_ref(&state.db, &pack_ref)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", pack_ref)))?;
let all_runtimes = RuntimeRepository::find_by_pack(&state.db, pack.id).await?;
let total = all_runtimes.len() as u64;
let rows: Vec<_> = all_runtimes
.into_iter()
.skip(pagination.offset() as usize)
.take(pagination.limit() as usize)
.collect();
let response = PaginatedResponse::new(
rows.into_iter().map(RuntimeSummary::from).collect(),
&pagination,
total,
);
Ok((StatusCode::OK, Json(response)))
}
#[utoipa::path(
get,
path = "/api/v1/runtimes/{ref}",
tag = "runtimes",
params(("ref" = String, Path, description = "Runtime reference identifier")),
responses(
(status = 200, description = "Runtime details", body = ApiResponse<RuntimeResponse>),
(status = 404, description = "Runtime not found")
),
security(("bearer_auth" = []))
)]
pub async fn get_runtime(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
Path(runtime_ref): Path<String>,
) -> ApiResult<impl IntoResponse> {
let runtime = RuntimeRepository::find_by_ref(&state.db, &runtime_ref)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Runtime '{}' not found", runtime_ref)))?;
Ok((
StatusCode::OK,
Json(ApiResponse::new(RuntimeResponse::from(runtime))),
))
}
#[utoipa::path(
post,
path = "/api/v1/runtimes",
tag = "runtimes",
request_body = CreateRuntimeRequest,
responses(
(status = 201, description = "Runtime created successfully", body = ApiResponse<RuntimeResponse>),
(status = 400, description = "Validation error"),
(status = 404, description = "Pack not found"),
(status = 409, description = "Runtime with same ref already exists")
),
security(("bearer_auth" = []))
)]
pub async fn create_runtime(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
Json(request): Json<CreateRuntimeRequest>,
) -> ApiResult<impl IntoResponse> {
request.validate()?;
if RuntimeRepository::find_by_ref(&state.db, &request.r#ref)
.await?
.is_some()
{
return Err(ApiError::Conflict(format!(
"Runtime with ref '{}' already exists",
request.r#ref
)));
}
let (pack_id, pack_ref) = if let Some(ref pack_ref_str) = request.pack_ref {
let pack = PackRepository::find_by_ref(&state.db, pack_ref_str)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", pack_ref_str)))?;
(Some(pack.id), Some(pack.r#ref))
} else {
(None, None)
};
let runtime = RuntimeRepository::create(
&state.db,
CreateRuntimeInput {
r#ref: request.r#ref,
pack: pack_id,
pack_ref,
description: request.description,
name: request.name,
aliases: vec![],
distributions: request.distributions,
installation: request.installation,
execution_config: request.execution_config,
auto_detected: false,
detection_config: serde_json::json!({}),
},
)
.await?;
Ok((
StatusCode::CREATED,
Json(ApiResponse::with_message(
RuntimeResponse::from(runtime),
"Runtime created successfully",
)),
))
}
#[utoipa::path(
put,
path = "/api/v1/runtimes/{ref}",
tag = "runtimes",
params(("ref" = String, Path, description = "Runtime reference identifier")),
request_body = UpdateRuntimeRequest,
responses(
(status = 200, description = "Runtime updated successfully", body = ApiResponse<RuntimeResponse>),
(status = 400, description = "Validation error"),
(status = 404, description = "Runtime not found")
),
security(("bearer_auth" = []))
)]
pub async fn update_runtime(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
Path(runtime_ref): Path<String>,
Json(request): Json<UpdateRuntimeRequest>,
) -> ApiResult<impl IntoResponse> {
request.validate()?;
let existing_runtime = RuntimeRepository::find_by_ref(&state.db, &runtime_ref)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Runtime '{}' not found", runtime_ref)))?;
let runtime = RuntimeRepository::update(
&state.db,
existing_runtime.id,
UpdateRuntimeInput {
description: request.description.map(|patch| match patch {
NullableStringPatch::Set(value) => Patch::Set(value),
NullableStringPatch::Clear => Patch::Clear,
}),
name: request.name,
distributions: request.distributions,
installation: request.installation.map(|patch| match patch {
NullableJsonPatch::Set(value) => Patch::Set(value),
NullableJsonPatch::Clear => Patch::Clear,
}),
execution_config: request.execution_config,
..Default::default()
},
)
.await?;
Ok((
StatusCode::OK,
Json(ApiResponse::with_message(
RuntimeResponse::from(runtime),
"Runtime updated successfully",
)),
))
}
#[utoipa::path(
delete,
path = "/api/v1/runtimes/{ref}",
tag = "runtimes",
params(("ref" = String, Path, description = "Runtime reference identifier")),
responses(
(status = 200, description = "Runtime deleted successfully", body = SuccessResponse),
(status = 404, description = "Runtime not found")
),
security(("bearer_auth" = []))
)]
pub async fn delete_runtime(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
Path(runtime_ref): Path<String>,
) -> ApiResult<impl IntoResponse> {
let runtime = RuntimeRepository::find_by_ref(&state.db, &runtime_ref)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Runtime '{}' not found", runtime_ref)))?;
let deleted = RuntimeRepository::delete(&state.db, runtime.id).await?;
if !deleted {
return Err(ApiError::NotFound(format!(
"Runtime '{}' not found",
runtime_ref
)));
}
Ok((
StatusCode::OK,
Json(SuccessResponse::new(format!(
"Runtime '{}' deleted successfully",
runtime_ref
))),
))
}
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/runtimes", get(list_runtimes).post(create_runtime))
.route(
"/runtimes/{ref}",
get(get_runtime).put(update_runtime).delete(delete_runtime),
)
.route("/packs/{pack_ref}/runtimes", get(list_runtimes_by_pack))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_runtime_routes_structure() {
let _router = routes();
}
}

View File

@@ -17,7 +17,7 @@ use attune_common::repositories::{
CreateSensorInput, CreateTriggerInput, SensorRepository, SensorSearchFilters, CreateSensorInput, CreateTriggerInput, SensorRepository, SensorSearchFilters,
TriggerRepository, TriggerSearchFilters, UpdateSensorInput, UpdateTriggerInput, TriggerRepository, TriggerSearchFilters, UpdateSensorInput, UpdateTriggerInput,
}, },
Create, Delete, FindByRef, Update, Create, Delete, FindByRef, Patch, Update,
}; };
use crate::{ use crate::{
@@ -25,8 +25,9 @@ use crate::{
dto::{ dto::{
common::{PaginatedResponse, PaginationParams}, common::{PaginatedResponse, PaginationParams},
trigger::{ trigger::{
CreateSensorRequest, CreateTriggerRequest, SensorResponse, SensorSummary, CreateSensorRequest, CreateTriggerRequest, SensorJsonPatch, SensorResponse,
TriggerResponse, TriggerSummary, UpdateSensorRequest, UpdateTriggerRequest, SensorSummary, TriggerJsonPatch, TriggerResponse, TriggerStringPatch, TriggerSummary,
UpdateSensorRequest, UpdateTriggerRequest,
}, },
ApiResponse, SuccessResponse, ApiResponse, SuccessResponse,
}, },
@@ -274,10 +275,19 @@ pub async fn update_trigger(
// Create update input // Create update input
let update_input = UpdateTriggerInput { let update_input = UpdateTriggerInput {
label: request.label, label: request.label,
description: request.description, description: request.description.map(|patch| match patch {
TriggerStringPatch::Set(value) => Patch::Set(value),
TriggerStringPatch::Clear => Patch::Clear,
}),
enabled: request.enabled, enabled: request.enabled,
param_schema: request.param_schema, param_schema: request.param_schema.map(|patch| match patch {
out_schema: request.out_schema, TriggerJsonPatch::Set(value) => Patch::Set(value),
TriggerJsonPatch::Clear => Patch::Clear,
}),
out_schema: request.out_schema.map(|patch| match patch {
TriggerJsonPatch::Set(value) => Patch::Set(value),
TriggerJsonPatch::Clear => Patch::Clear,
}),
}; };
let trigger = TriggerRepository::update(&state.db, existing_trigger.id, update_input).await?; let trigger = TriggerRepository::update(&state.db, existing_trigger.id, update_input).await?;
@@ -714,7 +724,7 @@ pub async fn update_sensor(
// Create update input // Create update input
let update_input = UpdateSensorInput { let update_input = UpdateSensorInput {
label: request.label, label: request.label,
description: request.description, description: request.description.map(Patch::Set),
entrypoint: request.entrypoint, entrypoint: request.entrypoint,
runtime: None, runtime: None,
runtime_ref: None, runtime_ref: None,
@@ -722,7 +732,10 @@ pub async fn update_sensor(
trigger: None, trigger: None,
trigger_ref: None, trigger_ref: None,
enabled: request.enabled, enabled: request.enabled,
param_schema: request.param_schema, param_schema: request.param_schema.map(|patch| match patch {
SensorJsonPatch::Set(value) => Patch::Set(value),
SensorJsonPatch::Clear => Patch::Clear,
}),
config: None, config: None,
}; };

View File

@@ -20,8 +20,11 @@ use attune_common::{
}, },
}; };
use attune_common::rbac::{Action, AuthorizationContext, Resource};
use crate::{ use crate::{
auth::middleware::RequireAuth, auth::middleware::RequireAuth,
authz::{AuthorizationCheck, AuthorizationService},
dto::{ dto::{
trigger::TriggerResponse, trigger::TriggerResponse,
webhook::{WebhookReceiverRequest, WebhookReceiverResponse}, webhook::{WebhookReceiverRequest, WebhookReceiverResponse},
@@ -170,7 +173,7 @@ fn get_webhook_config_array(
)] )]
pub async fn enable_webhook( pub async fn enable_webhook(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
Path(trigger_ref): Path<String>, Path(trigger_ref): Path<String>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// First, find the trigger by ref to get its ID // First, find the trigger by ref to get its ID
@@ -179,6 +182,26 @@ pub async fn enable_webhook(
.map_err(|e| ApiError::InternalServerError(e.to_string()))? .map_err(|e| ApiError::InternalServerError(e.to_string()))?
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_ref = Some(trigger.r#ref.clone());
ctx.pack_ref = trigger.pack_ref.clone();
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Triggers,
action: Action::Update,
context: ctx,
},
)
.await?;
}
// Enable webhooks for this trigger // Enable webhooks for this trigger
let _webhook_info = TriggerRepository::enable_webhook(&state.db, trigger.id) let _webhook_info = TriggerRepository::enable_webhook(&state.db, trigger.id)
.await .await
@@ -213,7 +236,7 @@ pub async fn enable_webhook(
)] )]
pub async fn disable_webhook( pub async fn disable_webhook(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
Path(trigger_ref): Path<String>, Path(trigger_ref): Path<String>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// First, find the trigger by ref to get its ID // First, find the trigger by ref to get its ID
@@ -222,6 +245,26 @@ pub async fn disable_webhook(
.map_err(|e| ApiError::InternalServerError(e.to_string()))? .map_err(|e| ApiError::InternalServerError(e.to_string()))?
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_ref = Some(trigger.r#ref.clone());
ctx.pack_ref = trigger.pack_ref.clone();
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Triggers,
action: Action::Update,
context: ctx,
},
)
.await?;
}
// Disable webhooks for this trigger // Disable webhooks for this trigger
TriggerRepository::disable_webhook(&state.db, trigger.id) TriggerRepository::disable_webhook(&state.db, trigger.id)
.await .await
@@ -257,7 +300,7 @@ pub async fn disable_webhook(
)] )]
pub async fn regenerate_webhook_key( pub async fn regenerate_webhook_key(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
Path(trigger_ref): Path<String>, Path(trigger_ref): Path<String>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// First, find the trigger by ref to get its ID // First, find the trigger by ref to get its ID
@@ -266,6 +309,26 @@ pub async fn regenerate_webhook_key(
.map_err(|e| ApiError::InternalServerError(e.to_string()))? .map_err(|e| ApiError::InternalServerError(e.to_string()))?
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_ref = Some(trigger.r#ref.clone());
ctx.pack_ref = trigger.pack_ref.clone();
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Triggers,
action: Action::Update,
context: ctx,
},
)
.await?;
}
// Check if webhooks are enabled // Check if webhooks are enabled
if !trigger.webhook_enabled { if !trigger.webhook_enabled {
return Err(ApiError::BadRequest( return Err(ApiError::BadRequest(

View File

@@ -18,7 +18,7 @@ use attune_common::repositories::{
CreateWorkflowDefinitionInput, UpdateWorkflowDefinitionInput, WorkflowDefinitionRepository, CreateWorkflowDefinitionInput, UpdateWorkflowDefinitionInput, WorkflowDefinitionRepository,
WorkflowSearchFilters, WorkflowSearchFilters,
}, },
Create, Delete, FindByRef, Update, Create, Delete, FindByRef, Patch, Update,
}; };
use crate::{ use crate::{
@@ -66,7 +66,6 @@ pub async fn list_workflows(
let filters = WorkflowSearchFilters { let filters = WorkflowSearchFilters {
pack: None, pack: None,
pack_ref: search_params.pack_ref.clone(), pack_ref: search_params.pack_ref.clone(),
enabled: search_params.enabled,
tags, tags,
search: search_params.search.clone(), search: search_params.search.clone(),
limit: pagination.limit(), limit: pagination.limit(),
@@ -113,7 +112,6 @@ pub async fn list_workflows_by_pack(
let filters = WorkflowSearchFilters { let filters = WorkflowSearchFilters {
pack: None, pack: None,
pack_ref: Some(pack_ref), pack_ref: Some(pack_ref),
enabled: None,
tags: None, tags: None,
search: None, search: None,
limit: pagination.limit(), limit: pagination.limit(),
@@ -208,7 +206,6 @@ pub async fn create_workflow(
out_schema: request.out_schema.clone(), out_schema: request.out_schema.clone(),
definition: request.definition, definition: request.definition,
tags: request.tags.clone().unwrap_or_default(), tags: request.tags.clone().unwrap_or_default(),
enabled: request.enabled.unwrap_or(true),
}; };
let workflow = WorkflowDefinitionRepository::create(&state.db, workflow_input).await?; let workflow = WorkflowDefinitionRepository::create(&state.db, workflow_input).await?;
@@ -220,7 +217,7 @@ pub async fn create_workflow(
pack.id, pack.id,
&pack.r#ref, &pack.r#ref,
&request.label, &request.label,
&request.description.clone().unwrap_or_default(), request.description.as_deref(),
"workflow", "workflow",
request.param_schema.as_ref(), request.param_schema.as_ref(),
request.out_schema.as_ref(), request.out_schema.as_ref(),
@@ -275,7 +272,6 @@ pub async fn update_workflow(
out_schema: request.out_schema.clone(), out_schema: request.out_schema.clone(),
definition: request.definition, definition: request.definition,
tags: request.tags, tags: request.tags,
enabled: request.enabled,
}; };
let workflow = let workflow =
@@ -408,7 +404,6 @@ pub async fn save_workflow_file(
out_schema: request.out_schema.clone(), out_schema: request.out_schema.clone(),
definition: definition_json, definition: definition_json,
tags: request.tags.clone().unwrap_or_default(), tags: request.tags.clone().unwrap_or_default(),
enabled: request.enabled.unwrap_or(true),
}; };
let workflow = WorkflowDefinitionRepository::create(&state.db, workflow_input).await?; let workflow = WorkflowDefinitionRepository::create(&state.db, workflow_input).await?;
@@ -421,7 +416,7 @@ pub async fn save_workflow_file(
pack.id, pack.id,
&pack.r#ref, &pack.r#ref,
&request.label, &request.label,
&request.description.clone().unwrap_or_default(), request.description.as_deref(),
&entrypoint, &entrypoint,
request.param_schema.as_ref(), request.param_schema.as_ref(),
request.out_schema.as_ref(), request.out_schema.as_ref(),
@@ -489,7 +484,6 @@ pub async fn update_workflow_file(
out_schema: request.out_schema.clone(), out_schema: request.out_schema.clone(),
definition: Some(definition_json), definition: Some(definition_json),
tags: request.tags, tags: request.tags,
enabled: request.enabled,
}; };
let workflow = let workflow =
@@ -505,7 +499,7 @@ pub async fn update_workflow_file(
pack.id, pack.id,
&pack.r#ref, &pack.r#ref,
&request.label, &request.label,
&request.description.unwrap_or_default(), request.description.as_deref(),
&entrypoint, &entrypoint,
request.param_schema.as_ref(), request.param_schema.as_ref(),
request.out_schema.as_ref(), request.out_schema.as_ref(),
@@ -647,7 +641,6 @@ fn build_action_yaml(pack_ref: &str, request: &SaveWorkflowFileRequest) -> Strin
lines.push(format!("description: \"{}\"", desc.replace('"', "\\\""))); lines.push(format!("description: \"{}\"", desc.replace('"', "\\\"")));
} }
} }
lines.push("enabled: true".to_string());
lines.push(format!( lines.push(format!(
"workflow_file: workflows/{}.workflow.yaml", "workflow_file: workflows/{}.workflow.yaml",
request.name request.name
@@ -709,7 +702,7 @@ async fn create_companion_action(
pack_id: i64, pack_id: i64,
pack_ref: &str, pack_ref: &str,
label: &str, label: &str,
description: &str, description: Option<&str>,
entrypoint: &str, entrypoint: &str,
param_schema: Option<&serde_json::Value>, param_schema: Option<&serde_json::Value>,
out_schema: Option<&serde_json::Value>, out_schema: Option<&serde_json::Value>,
@@ -720,7 +713,7 @@ async fn create_companion_action(
pack: pack_id, pack: pack_id,
pack_ref: pack_ref.to_string(), pack_ref: pack_ref.to_string(),
label: label.to_string(), label: label.to_string(),
description: description.to_string(), description: description.map(|s| s.to_string()),
entrypoint: entrypoint.to_string(), entrypoint: entrypoint.to_string(),
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,
@@ -794,7 +787,7 @@ async fn update_companion_action(
if let Some(action) = existing_action { if let Some(action) = existing_action {
let update_input = UpdateActionInput { let update_input = UpdateActionInput {
label: label.map(|s| s.to_string()), label: label.map(|s| s.to_string()),
description: description.map(|s| s.to_string()), description: description.map(|s| Patch::Set(s.to_string())),
entrypoint: None, entrypoint: None,
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,
@@ -845,7 +838,7 @@ async fn ensure_companion_action(
pack_id: i64, pack_id: i64,
pack_ref: &str, pack_ref: &str,
label: &str, label: &str,
description: &str, description: Option<&str>,
entrypoint: &str, entrypoint: &str,
param_schema: Option<&serde_json::Value>, param_schema: Option<&serde_json::Value>,
out_schema: Option<&serde_json::Value>, out_schema: Option<&serde_json::Value>,
@@ -860,7 +853,10 @@ async fn ensure_companion_action(
// Update existing companion action // Update existing companion action
let update_input = UpdateActionInput { let update_input = UpdateActionInput {
label: Some(label.to_string()), label: Some(label.to_string()),
description: Some(description.to_string()), description: Some(match description {
Some(description) => Patch::Set(description.to_string()),
None => Patch::Clear,
}),
entrypoint: Some(entrypoint.to_string()), entrypoint: Some(entrypoint.to_string()),
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,

View File

@@ -47,17 +47,20 @@ impl Server {
let api_v1 = Router::new() let api_v1 = Router::new()
.merge(routes::pack_routes()) .merge(routes::pack_routes())
.merge(routes::action_routes()) .merge(routes::action_routes())
.merge(routes::runtime_routes())
.merge(routes::rule_routes()) .merge(routes::rule_routes())
.merge(routes::execution_routes()) .merge(routes::execution_routes())
.merge(routes::trigger_routes()) .merge(routes::trigger_routes())
.merge(routes::inquiry_routes()) .merge(routes::inquiry_routes())
.merge(routes::event_routes()) .merge(routes::event_routes())
.merge(routes::key_routes()) .merge(routes::key_routes())
.merge(routes::permission_routes())
.merge(routes::workflow_routes()) .merge(routes::workflow_routes())
.merge(routes::webhook_routes()) .merge(routes::webhook_routes())
.merge(routes::history_routes()) .merge(routes::history_routes())
.merge(routes::analytics_routes()) .merge(routes::analytics_routes())
.merge(routes::artifact_routes()) .merge(routes::artifact_routes())
.merge(routes::agent_routes())
.with_state(self.state.clone()); .with_state(self.state.clone());
// Auth routes at root level (not versioned for frontend compatibility) // Auth routes at root level (not versioned for frontend compatibility)

View File

@@ -362,7 +362,7 @@ mod tests {
pack: 1, pack: 1,
pack_ref: "test".to_string(), pack_ref: "test".to_string(),
label: "Test Action".to_string(), label: "Test Action".to_string(),
description: "Test action".to_string(), description: Some("Test action".to_string()),
entrypoint: "test.sh".to_string(), entrypoint: "test.sh".to_string(),
runtime: Some(1), runtime: Some(1),
runtime_version_constraint: None, runtime_version_constraint: None,

View File

@@ -0,0 +1,138 @@
//! Integration tests for agent binary distribution endpoints
//!
//! The agent endpoints (`/api/v1/agent/binary` and `/api/v1/agent/info`) are
//! intentionally unauthenticated — the agent needs to download its binary
//! before it has JWT credentials. An optional `bootstrap_token` can restrict
//! access, but that is validated inside the handler, not via RequireAuth
//! middleware.
//!
//! The test configuration (`config.test.yaml`) does NOT include an `agent`
//! section, so both endpoints return 503 Service Unavailable. This is the
//! correct behaviour: the endpoints are reachable (no 401/404 from middleware)
//! but the feature is not configured.
use axum::http::StatusCode;
#[allow(dead_code)]
mod helpers;
use helpers::TestContext;
// ── /api/v1/agent/info ──────────────────────────────────────────────
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_agent_info_not_configured() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.get("/api/v1/agent/info", None)
.await
.expect("Failed to make request");
// Agent config is not set in config.test.yaml, so the handler returns 503.
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body: serde_json::Value = response.json().await.expect("Failed to parse JSON");
assert_eq!(body["error"], "Not configured");
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_agent_info_no_auth_required() {
// Verify that the endpoint is reachable WITHOUT any JWT token.
// If RequireAuth middleware were applied, this would return 401.
// Instead we expect 503 (not configured) — proving the endpoint
// is publicly accessible.
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.get("/api/v1/agent/info", None)
.await
.expect("Failed to make request");
// Must NOT be 401 Unauthorized — the endpoint has no auth middleware.
assert_ne!(
response.status(),
StatusCode::UNAUTHORIZED,
"agent/info should not require authentication"
);
// Should be 503 because agent config is absent.
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
// ── /api/v1/agent/binary ────────────────────────────────────────────
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_agent_binary_not_configured() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.get("/api/v1/agent/binary", None)
.await
.expect("Failed to make request");
// Agent config is not set in config.test.yaml, so the handler returns 503.
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body: serde_json::Value = response.json().await.expect("Failed to parse JSON");
assert_eq!(body["error"], "Not configured");
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_agent_binary_no_auth_required() {
// Same reasoning as test_agent_info_no_auth_required: the binary
// download endpoint must be publicly accessible (no RequireAuth).
// When no bootstrap_token is configured, any caller can reach the
// handler. We still get 503 because the agent feature itself is
// not configured in the test environment.
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.get("/api/v1/agent/binary", None)
.await
.expect("Failed to make request");
// Must NOT be 401 Unauthorized — the endpoint has no auth middleware.
assert_ne!(
response.status(),
StatusCode::UNAUTHORIZED,
"agent/binary should not require authentication when no bootstrap_token is configured"
);
// Should be 503 because agent config is absent.
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_agent_binary_invalid_arch() {
// Architecture validation (`validate_arch`) rejects unsupported values
// with 400 Bad Request. However, in the handler the execution order is:
// 1. validate_token (passes — no bootstrap_token configured)
// 2. check agent config (fails with 503 — not configured)
// 3. validate_arch (never reached)
//
// So even with an invalid arch like "mips", we get 503 from the config
// check before the arch is ever validated. The arch validation is covered
// by unit tests in routes/agent.rs instead.
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.get("/api/v1/agent/binary?arch=mips", None)
.await
.expect("Failed to make request");
// 503 from the agent-config-not-set check, NOT 400 from arch validation.
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}

View File

@@ -7,6 +7,7 @@ use serde_json::json;
mod helpers; mod helpers;
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_register_debug() { async fn test_register_debug() {
let ctx = TestContext::new() let ctx = TestContext::new()
.await .await
@@ -36,6 +37,7 @@ async fn test_register_debug() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_health_check() { async fn test_health_check() {
let ctx = TestContext::new() let ctx = TestContext::new()
.await .await
@@ -54,6 +56,7 @@ async fn test_health_check() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_health_detailed() { async fn test_health_detailed() {
let ctx = TestContext::new() let ctx = TestContext::new()
.await .await
@@ -74,6 +77,7 @@ async fn test_health_detailed() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_health_ready() { async fn test_health_ready() {
let ctx = TestContext::new() let ctx = TestContext::new()
.await .await
@@ -90,6 +94,7 @@ async fn test_health_ready() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_health_live() { async fn test_health_live() {
let ctx = TestContext::new() let ctx = TestContext::new()
.await .await
@@ -106,6 +111,7 @@ async fn test_health_live() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_register_user() { async fn test_register_user() {
let ctx = TestContext::new() let ctx = TestContext::new()
.await .await
@@ -137,6 +143,7 @@ async fn test_register_user() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_register_duplicate_user() { async fn test_register_duplicate_user() {
let ctx = TestContext::new() let ctx = TestContext::new()
.await .await
@@ -174,6 +181,7 @@ async fn test_register_duplicate_user() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_register_invalid_password() { async fn test_register_invalid_password() {
let ctx = TestContext::new() let ctx = TestContext::new()
.await .await
@@ -196,6 +204,7 @@ async fn test_register_invalid_password() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_login_success() { async fn test_login_success() {
let ctx = TestContext::new() let ctx = TestContext::new()
.await .await
@@ -238,6 +247,7 @@ async fn test_login_success() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_login_wrong_password() { async fn test_login_wrong_password() {
let ctx = TestContext::new() let ctx = TestContext::new()
.await .await
@@ -274,6 +284,7 @@ async fn test_login_wrong_password() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_login_nonexistent_user() { async fn test_login_nonexistent_user() {
let ctx = TestContext::new() let ctx = TestContext::new()
.await .await
@@ -294,7 +305,128 @@ async fn test_login_nonexistent_user() {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
// ── LDAP auth tests ──────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_ldap_login_returns_501_when_not_configured() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.post(
"/auth/ldap/login",
json!({
"login": "jdoe",
"password": "secret"
}),
None,
)
.await
.expect("Failed to make request");
// LDAP is not configured in config.test.yaml, so the endpoint
// should return 501 Not Implemented.
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_ldap_login_validates_empty_login() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.post(
"/auth/ldap/login",
json!({
"login": "",
"password": "secret"
}),
None,
)
.await
.expect("Failed to make request");
// Validation should fail before we even check LDAP config
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_ldap_login_validates_empty_password() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.post(
"/auth/ldap/login",
json!({
"login": "jdoe",
"password": ""
}),
None,
)
.await
.expect("Failed to make request");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_ldap_login_validates_missing_fields() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.post("/auth/ldap/login", json!({}), None)
.await
.expect("Failed to make request");
// Missing required fields should return 422
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
// ── auth/settings LDAP field tests ──────────────────────────────────
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_auth_settings_includes_ldap_fields_disabled() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.get("/auth/settings", None)
.await
.expect("Failed to make request");
assert_eq!(response.status(), StatusCode::OK);
let body: serde_json::Value = response.json().await.expect("Failed to parse JSON");
// LDAP is not configured in config.test.yaml, so these should all
// reflect the disabled state.
assert_eq!(body["data"]["ldap_enabled"], false);
assert_eq!(body["data"]["ldap_visible_by_default"], false);
assert!(body["data"]["ldap_provider_name"].is_null());
assert!(body["data"]["ldap_provider_label"].is_null());
assert!(body["data"]["ldap_provider_icon_url"].is_null());
// Existing fields should still be present
assert!(body["data"]["authentication_enabled"].is_boolean());
assert!(body["data"]["local_password_enabled"].is_boolean());
assert!(body["data"]["oidc_enabled"].is_boolean());
assert!(body["data"]["self_registration_enabled"].is_boolean());
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_get_current_user() { async fn test_get_current_user() {
let ctx = TestContext::new() let ctx = TestContext::new()
.await .await
@@ -318,6 +450,7 @@ async fn test_get_current_user() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_get_current_user_unauthorized() { async fn test_get_current_user_unauthorized() {
let ctx = TestContext::new() let ctx = TestContext::new()
.await .await
@@ -332,6 +465,7 @@ async fn test_get_current_user_unauthorized() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_get_current_user_invalid_token() { async fn test_get_current_user_invalid_token() {
let ctx = TestContext::new() let ctx = TestContext::new()
.await .await
@@ -346,6 +480,7 @@ async fn test_get_current_user_invalid_token() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_refresh_token() { async fn test_refresh_token() {
let ctx = TestContext::new() let ctx = TestContext::new()
.await .await
@@ -396,6 +531,7 @@ async fn test_refresh_token() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_refresh_with_invalid_token() { async fn test_refresh_with_invalid_token() {
let ctx = TestContext::new() let ctx = TestContext::new()
.await .await

View File

@@ -9,6 +9,10 @@ use attune_common::{
models::*, models::*,
repositories::{ repositories::{
action::{ActionRepository, CreateActionInput}, action::{ActionRepository, CreateActionInput},
identity::{
CreatePermissionAssignmentInput, CreatePermissionSetInput,
PermissionAssignmentRepository, PermissionSetRepository,
},
pack::{CreatePackInput, PackRepository}, pack::{CreatePackInput, PackRepository},
trigger::{CreateTriggerInput, TriggerRepository}, trigger::{CreateTriggerInput, TriggerRepository},
workflow::{CreateWorkflowDefinitionInput, WorkflowDefinitionRepository}, workflow::{CreateWorkflowDefinitionInput, WorkflowDefinitionRepository},
@@ -237,6 +241,7 @@ impl TestContext {
} }
/// Create and authenticate a test user /// Create and authenticate a test user
#[allow(dead_code)]
pub async fn with_auth(mut self) -> Result<Self> { pub async fn with_auth(mut self) -> Result<Self> {
// Generate unique username to avoid conflicts in parallel tests // Generate unique username to avoid conflicts in parallel tests
let unique_id = uuid::Uuid::new_v4().to_string().replace("-", "")[..8].to_string(); let unique_id = uuid::Uuid::new_v4().to_string().replace("-", "")[..8].to_string();
@@ -246,6 +251,48 @@ impl TestContext {
Ok(self) Ok(self)
} }
/// Create and authenticate a test user with identity + permission admin grants.
#[allow(dead_code)]
pub async fn with_admin_auth(mut self) -> Result<Self> {
let unique_id = uuid::Uuid::new_v4().to_string().replace("-", "")[..8].to_string();
let login = format!("adminuser_{}", unique_id);
let token = self.create_test_user(&login).await?;
let identity = attune_common::repositories::identity::IdentityRepository::find_by_login(
&self.pool, &login,
)
.await?
.ok_or_else(|| format!("Failed to find newly created identity '{}'", login))?;
let permset = PermissionSetRepository::create(
&self.pool,
CreatePermissionSetInput {
r#ref: "core.admin".to_string(),
pack: None,
pack_ref: None,
label: Some("Admin".to_string()),
description: Some("Test admin permission set".to_string()),
grants: json!([
{"resource": "identities", "actions": ["read", "create", "update", "delete"]},
{"resource": "permissions", "actions": ["read", "create", "update", "delete", "manage"]}
]),
},
)
.await?;
PermissionAssignmentRepository::create(
&self.pool,
CreatePermissionAssignmentInput {
identity: identity.id,
permset: permset.id,
},
)
.await?;
self.token = Some(token);
Ok(self)
}
/// Create a test user and return access token /// Create a test user and return access token
async fn create_test_user(&self, login: &str) -> Result<String> { async fn create_test_user(&self, login: &str) -> Result<String> {
// Register via API to get real token // Register via API to get real token
@@ -348,6 +395,7 @@ impl TestContext {
} }
/// Get authenticated token /// Get authenticated token
#[allow(dead_code)]
pub fn token(&self) -> Option<&str> { pub fn token(&self) -> Option<&str> {
self.token.as_deref() self.token.as_deref()
} }
@@ -449,7 +497,7 @@ pub async fn create_test_action(pool: &PgPool, pack_id: i64, ref_name: &str) ->
pack: pack_id, pack: pack_id,
pack_ref: format!("pack_{}", pack_id), pack_ref: format!("pack_{}", pack_id),
label: format!("Test Action {}", ref_name), label: format!("Test Action {}", ref_name),
description: format!("Test action for {}", ref_name), description: Some(format!("Test action for {}", ref_name)),
entrypoint: "main.py".to_string(), entrypoint: "main.py".to_string(),
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,
@@ -506,7 +554,6 @@ pub async fn create_test_workflow(
] ]
}), }),
tags: vec!["test".to_string()], tags: vec!["test".to_string()],
enabled: true,
}; };
Ok(WorkflowDefinitionRepository::create(pool, input).await?) Ok(WorkflowDefinitionRepository::create(pool, input).await?)

View File

@@ -127,6 +127,7 @@ actions:
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_install_pack_from_local_directory() -> Result<()> { async fn test_install_pack_from_local_directory() -> Result<()> {
let ctx = TestContext::new().await?.with_auth().await?; let ctx = TestContext::new().await?.with_auth().await?;
let token = ctx.token().unwrap(); let token = ctx.token().unwrap();
@@ -166,6 +167,7 @@ async fn test_install_pack_from_local_directory() -> Result<()> {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_install_pack_with_dependency_validation_success() -> Result<()> { async fn test_install_pack_with_dependency_validation_success() -> Result<()> {
let ctx = TestContext::new().await?.with_auth().await?; let ctx = TestContext::new().await?.with_auth().await?;
let token = ctx.token().unwrap(); let token = ctx.token().unwrap();
@@ -216,6 +218,7 @@ async fn test_install_pack_with_dependency_validation_success() -> Result<()> {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_install_pack_with_missing_dependency_fails() -> Result<()> { async fn test_install_pack_with_missing_dependency_fails() -> Result<()> {
let ctx = TestContext::new().await?.with_auth().await?; let ctx = TestContext::new().await?.with_auth().await?;
let token = ctx.token().unwrap(); let token = ctx.token().unwrap();
@@ -255,6 +258,7 @@ async fn test_install_pack_with_missing_dependency_fails() -> Result<()> {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_install_pack_skip_deps_bypasses_validation() -> Result<()> { async fn test_install_pack_skip_deps_bypasses_validation() -> Result<()> {
let ctx = TestContext::new().await?.with_auth().await?; let ctx = TestContext::new().await?.with_auth().await?;
let token = ctx.token().unwrap(); let token = ctx.token().unwrap();
@@ -290,6 +294,7 @@ async fn test_install_pack_skip_deps_bypasses_validation() -> Result<()> {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_install_pack_with_runtime_validation() -> Result<()> { async fn test_install_pack_with_runtime_validation() -> Result<()> {
let ctx = TestContext::new().await?.with_auth().await?; let ctx = TestContext::new().await?.with_auth().await?;
let token = ctx.token().unwrap(); let token = ctx.token().unwrap();
@@ -323,6 +328,7 @@ async fn test_install_pack_with_runtime_validation() -> Result<()> {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_install_pack_metadata_tracking() -> Result<()> { async fn test_install_pack_metadata_tracking() -> Result<()> {
let ctx = TestContext::new().await?.with_auth().await?; let ctx = TestContext::new().await?.with_auth().await?;
let token = ctx.token().unwrap(); let token = ctx.token().unwrap();
@@ -372,6 +378,7 @@ async fn test_install_pack_metadata_tracking() -> Result<()> {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_install_pack_force_reinstall() -> Result<()> { async fn test_install_pack_force_reinstall() -> Result<()> {
let ctx = TestContext::new().await?.with_auth().await?; let ctx = TestContext::new().await?.with_auth().await?;
let token = ctx.token().unwrap(); let token = ctx.token().unwrap();
@@ -424,6 +431,7 @@ async fn test_install_pack_force_reinstall() -> Result<()> {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_install_pack_storage_path_created() -> Result<()> { async fn test_install_pack_storage_path_created() -> Result<()> {
let ctx = TestContext::new().await?.with_auth().await?; let ctx = TestContext::new().await?.with_auth().await?;
let token = ctx.token().unwrap(); let token = ctx.token().unwrap();
@@ -474,6 +482,7 @@ async fn test_install_pack_storage_path_created() -> Result<()> {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_install_pack_invalid_source() -> Result<()> { async fn test_install_pack_invalid_source() -> Result<()> {
let ctx = TestContext::new().await?.with_auth().await?; let ctx = TestContext::new().await?.with_auth().await?;
let token = ctx.token().unwrap(); let token = ctx.token().unwrap();
@@ -504,6 +513,7 @@ async fn test_install_pack_invalid_source() -> Result<()> {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_install_pack_missing_pack_yaml() -> Result<()> { async fn test_install_pack_missing_pack_yaml() -> Result<()> {
let ctx = TestContext::new().await?.with_auth().await?; let ctx = TestContext::new().await?.with_auth().await?;
let token = ctx.token().unwrap(); let token = ctx.token().unwrap();
@@ -538,6 +548,7 @@ async fn test_install_pack_missing_pack_yaml() -> Result<()> {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_install_pack_invalid_pack_yaml() -> Result<()> { async fn test_install_pack_invalid_pack_yaml() -> Result<()> {
let ctx = TestContext::new().await?.with_auth().await?; let ctx = TestContext::new().await?.with_auth().await?;
let token = ctx.token().unwrap(); let token = ctx.token().unwrap();
@@ -566,6 +577,7 @@ async fn test_install_pack_invalid_pack_yaml() -> Result<()> {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_install_pack_without_auth_fails() -> Result<()> { async fn test_install_pack_without_auth_fails() -> Result<()> {
let ctx = TestContext::new().await?; // No auth let ctx = TestContext::new().await?; // No auth
@@ -591,6 +603,7 @@ async fn test_install_pack_without_auth_fails() -> Result<()> {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_multiple_pack_installations() -> Result<()> { async fn test_multiple_pack_installations() -> Result<()> {
let ctx = TestContext::new().await?.with_auth().await?; let ctx = TestContext::new().await?.with_auth().await?;
let token = ctx.token().unwrap(); let token = ctx.token().unwrap();
@@ -638,6 +651,7 @@ async fn test_multiple_pack_installations() -> Result<()> {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_install_pack_version_upgrade() -> Result<()> { async fn test_install_pack_version_upgrade() -> Result<()> {
let ctx = TestContext::new().await?.with_auth().await?; let ctx = TestContext::new().await?.with_auth().await?;
let token = ctx.token().unwrap(); let token = ctx.token().unwrap();

View File

@@ -22,7 +22,6 @@ ref: {}.example_workflow
label: Example Workflow label: Example Workflow
description: A test workflow for integration testing description: A test workflow for integration testing
version: "1.0.0" version: "1.0.0"
enabled: true
parameters: parameters:
message: message:
type: string type: string
@@ -46,7 +45,6 @@ ref: {}.another_workflow
label: Another Workflow label: Another Workflow
description: Second test workflow description: Second test workflow
version: "1.0.0" version: "1.0.0"
enabled: false
tasks: tasks:
- name: task1 - name: task1
action: core.noop action: core.noop
@@ -58,6 +56,7 @@ tasks:
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_sync_pack_workflows_endpoint() { async fn test_sync_pack_workflows_endpoint() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -94,6 +93,7 @@ async fn test_sync_pack_workflows_endpoint() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_validate_pack_workflows_endpoint() { async fn test_validate_pack_workflows_endpoint() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -120,6 +120,7 @@ async fn test_validate_pack_workflows_endpoint() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_sync_nonexistent_pack_returns_404() { async fn test_sync_nonexistent_pack_returns_404() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -136,6 +137,7 @@ async fn test_sync_nonexistent_pack_returns_404() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_validate_nonexistent_pack_returns_404() { async fn test_validate_nonexistent_pack_returns_404() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -152,6 +154,7 @@ async fn test_validate_nonexistent_pack_returns_404() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_sync_workflows_requires_authentication() { async fn test_sync_workflows_requires_authentication() {
let ctx = TestContext::new().await.unwrap(); let ctx = TestContext::new().await.unwrap();
@@ -179,6 +182,7 @@ async fn test_sync_workflows_requires_authentication() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_validate_workflows_requires_authentication() { async fn test_validate_workflows_requires_authentication() {
let ctx = TestContext::new().await.unwrap(); let ctx = TestContext::new().await.unwrap();
@@ -206,6 +210,7 @@ async fn test_validate_workflows_requires_authentication() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_pack_creation_with_auto_sync() { async fn test_pack_creation_with_auto_sync() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -236,6 +241,7 @@ async fn test_pack_creation_with_auto_sync() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_pack_update_with_auto_resync() { async fn test_pack_update_with_auto_resync() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();

View File

@@ -0,0 +1,178 @@
use axum::http::StatusCode;
use helpers::*;
use serde_json::json;
mod helpers;
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_identity_crud_and_permission_assignment_flow() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context")
.with_admin_auth()
.await
.expect("Failed to create admin-authenticated test user");
let create_identity_response = ctx
.post(
"/api/v1/identities",
json!({
"login": "managed_user",
"display_name": "Managed User",
"password": "ManagedPass123!",
"attributes": {
"department": "platform"
}
}),
ctx.token(),
)
.await
.expect("Failed to create identity");
assert_eq!(create_identity_response.status(), StatusCode::CREATED);
let created_identity: serde_json::Value = create_identity_response
.json()
.await
.expect("Failed to parse identity create response");
let identity_id = created_identity["data"]["id"]
.as_i64()
.expect("Missing identity id");
let list_identities_response = ctx
.get("/api/v1/identities", ctx.token())
.await
.expect("Failed to list identities");
assert_eq!(list_identities_response.status(), StatusCode::OK);
let identities_body: serde_json::Value = list_identities_response
.json()
.await
.expect("Failed to parse identities response");
assert!(identities_body["data"]
.as_array()
.expect("Expected data array")
.iter()
.any(|item| item["login"] == "managed_user"));
let update_identity_response = ctx
.put(
&format!("/api/v1/identities/{}", identity_id),
json!({
"display_name": "Managed User Updated",
"attributes": {
"department": "security"
}
}),
ctx.token(),
)
.await
.expect("Failed to update identity");
assert_eq!(update_identity_response.status(), StatusCode::OK);
let get_identity_response = ctx
.get(&format!("/api/v1/identities/{}", identity_id), ctx.token())
.await
.expect("Failed to get identity");
assert_eq!(get_identity_response.status(), StatusCode::OK);
let identity_body: serde_json::Value = get_identity_response
.json()
.await
.expect("Failed to parse get identity response");
assert_eq!(
identity_body["data"]["display_name"],
"Managed User Updated"
);
assert_eq!(
identity_body["data"]["attributes"]["department"],
"security"
);
let permission_sets_response = ctx
.get("/api/v1/permissions/sets", ctx.token())
.await
.expect("Failed to list permission sets");
assert_eq!(permission_sets_response.status(), StatusCode::OK);
let assignment_response = ctx
.post(
"/api/v1/permissions/assignments",
json!({
"identity_id": identity_id,
"permission_set_ref": "core.admin"
}),
ctx.token(),
)
.await
.expect("Failed to create permission assignment");
assert_eq!(assignment_response.status(), StatusCode::CREATED);
let assignment_body: serde_json::Value = assignment_response
.json()
.await
.expect("Failed to parse permission assignment response");
let assignment_id = assignment_body["data"]["id"]
.as_i64()
.expect("Missing assignment id");
assert_eq!(assignment_body["data"]["permission_set_ref"], "core.admin");
let list_assignments_response = ctx
.get(
&format!("/api/v1/identities/{}/permissions", identity_id),
ctx.token(),
)
.await
.expect("Failed to list identity permissions");
assert_eq!(list_assignments_response.status(), StatusCode::OK);
let assignments_body: serde_json::Value = list_assignments_response
.json()
.await
.expect("Failed to parse identity permissions response");
assert!(assignments_body
.as_array()
.expect("Expected array response")
.iter()
.any(|item| item["permission_set_ref"] == "core.admin"));
let delete_assignment_response = ctx
.delete(
&format!("/api/v1/permissions/assignments/{}", assignment_id),
ctx.token(),
)
.await
.expect("Failed to delete assignment");
assert_eq!(delete_assignment_response.status(), StatusCode::OK);
let delete_identity_response = ctx
.delete(&format!("/api/v1/identities/{}", identity_id), ctx.token())
.await
.expect("Failed to delete identity");
assert_eq!(delete_identity_response.status(), StatusCode::OK);
let missing_identity_response = ctx
.get(&format!("/api/v1/identities/{}", identity_id), ctx.token())
.await
.expect("Failed to fetch deleted identity");
assert_eq!(missing_identity_response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_plain_authenticated_user_cannot_manage_identities() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context")
.with_auth()
.await
.expect("Failed to authenticate plain test user");
let response = ctx
.get("/api/v1/identities", ctx.token())
.await
.expect("Failed to call identities endpoint");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}

View File

@@ -0,0 +1,276 @@
use axum::http::StatusCode;
use helpers::*;
use serde_json::json;
use attune_common::{
models::enums::{ArtifactType, ArtifactVisibility, OwnerType, RetentionPolicyType},
repositories::{
artifact::{ArtifactRepository, CreateArtifactInput},
identity::{
CreatePermissionAssignmentInput, CreatePermissionSetInput, IdentityRepository,
PermissionAssignmentRepository, PermissionSetRepository,
},
key::{CreateKeyInput, KeyRepository},
Create,
},
};
mod helpers;
async fn register_scoped_user(
ctx: &TestContext,
login: &str,
grants: serde_json::Value,
) -> Result<String> {
let response = ctx
.post(
"/auth/register",
json!({
"login": login,
"password": "TestPassword123!",
"display_name": format!("Scoped User {}", login),
}),
None,
)
.await?;
assert_eq!(response.status(), StatusCode::CREATED);
let body: serde_json::Value = response.json().await?;
let token = body["data"]["access_token"]
.as_str()
.expect("missing access token")
.to_string();
let identity = IdentityRepository::find_by_login(&ctx.pool, login)
.await?
.expect("registered identity should exist");
let permset = PermissionSetRepository::create(
&ctx.pool,
CreatePermissionSetInput {
r#ref: format!("test.scoped_{}", uuid::Uuid::new_v4().simple()),
pack: None,
pack_ref: None,
label: Some("Scoped Test Permission Set".to_string()),
description: Some("Scoped test grants".to_string()),
grants,
},
)
.await?;
PermissionAssignmentRepository::create(
&ctx.pool,
CreatePermissionAssignmentInput {
identity: identity.id,
permset: permset.id,
},
)
.await?;
Ok(token)
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_pack_scoped_key_permissions_enforce_owner_refs() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let token = register_scoped_user(
&ctx,
&format!("scoped_keys_{}", uuid::Uuid::new_v4().simple()),
json!([
{
"resource": "keys",
"actions": ["read"],
"constraints": {
"owner_types": ["pack"],
"owner_refs": ["python_example"]
}
}
]),
)
.await
.expect("Failed to register scoped user");
KeyRepository::create(
&ctx.pool,
CreateKeyInput {
r#ref: format!("python_example_key_{}", uuid::Uuid::new_v4().simple()),
owner_type: OwnerType::Pack,
owner: Some("python_example".to_string()),
owner_identity: None,
owner_pack: None,
owner_pack_ref: Some("python_example".to_string()),
owner_action: None,
owner_action_ref: None,
owner_sensor: None,
owner_sensor_ref: None,
name: "Python Example Key".to_string(),
encrypted: false,
encryption_key_hash: None,
value: json!("allowed"),
},
)
.await
.expect("Failed to create scoped key");
let blocked_key = KeyRepository::create(
&ctx.pool,
CreateKeyInput {
r#ref: format!("other_pack_key_{}", uuid::Uuid::new_v4().simple()),
owner_type: OwnerType::Pack,
owner: Some("other_pack".to_string()),
owner_identity: None,
owner_pack: None,
owner_pack_ref: Some("other_pack".to_string()),
owner_action: None,
owner_action_ref: None,
owner_sensor: None,
owner_sensor_ref: None,
name: "Other Pack Key".to_string(),
encrypted: false,
encryption_key_hash: None,
value: json!("blocked"),
},
)
.await
.expect("Failed to create blocked key");
let allowed_list = ctx
.get("/api/v1/keys", Some(&token))
.await
.expect("Failed to list keys");
assert_eq!(allowed_list.status(), StatusCode::OK);
let allowed_body: serde_json::Value = allowed_list.json().await.expect("Invalid key list");
assert_eq!(
allowed_body["data"]
.as_array()
.expect("expected list")
.len(),
1
);
assert_eq!(allowed_body["data"][0]["owner"], "python_example");
let blocked_get = ctx
.get(&format!("/api/v1/keys/{}", blocked_key.r#ref), Some(&token))
.await
.expect("Failed to fetch blocked key");
assert_eq!(blocked_get.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_pack_scoped_artifact_permissions_enforce_owner_refs() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let token = register_scoped_user(
&ctx,
&format!("scoped_artifacts_{}", uuid::Uuid::new_v4().simple()),
json!([
{
"resource": "artifacts",
"actions": ["read", "create"],
"constraints": {
"owner_types": ["pack"],
"owner_refs": ["python_example"]
}
}
]),
)
.await
.expect("Failed to register scoped user");
let allowed_artifact = ArtifactRepository::create(
&ctx.pool,
CreateArtifactInput {
r#ref: format!("python_example.allowed_{}", uuid::Uuid::new_v4().simple()),
scope: OwnerType::Pack,
owner: "python_example".to_string(),
r#type: ArtifactType::FileText,
visibility: ArtifactVisibility::Private,
retention_policy: RetentionPolicyType::Versions,
retention_limit: 5,
name: Some("Allowed Artifact".to_string()),
description: None,
content_type: Some("text/plain".to_string()),
execution: None,
data: None,
},
)
.await
.expect("Failed to create allowed artifact");
let blocked_artifact = ArtifactRepository::create(
&ctx.pool,
CreateArtifactInput {
r#ref: format!("other_pack.blocked_{}", uuid::Uuid::new_v4().simple()),
scope: OwnerType::Pack,
owner: "other_pack".to_string(),
r#type: ArtifactType::FileText,
visibility: ArtifactVisibility::Private,
retention_policy: RetentionPolicyType::Versions,
retention_limit: 5,
name: Some("Blocked Artifact".to_string()),
description: None,
content_type: Some("text/plain".to_string()),
execution: None,
data: None,
},
)
.await
.expect("Failed to create blocked artifact");
let allowed_get = ctx
.get(
&format!("/api/v1/artifacts/{}", allowed_artifact.id),
Some(&token),
)
.await
.expect("Failed to fetch allowed artifact");
assert_eq!(allowed_get.status(), StatusCode::OK);
let blocked_get = ctx
.get(
&format!("/api/v1/artifacts/{}", blocked_artifact.id),
Some(&token),
)
.await
.expect("Failed to fetch blocked artifact");
assert_eq!(blocked_get.status(), StatusCode::NOT_FOUND);
let create_allowed = ctx
.post(
"/api/v1/artifacts",
json!({
"ref": format!("python_example.created_{}", uuid::Uuid::new_v4().simple()),
"scope": "pack",
"owner": "python_example",
"type": "file_text",
"name": "Created Artifact"
}),
Some(&token),
)
.await
.expect("Failed to create allowed artifact");
assert_eq!(create_allowed.status(), StatusCode::CREATED);
let create_blocked = ctx
.post(
"/api/v1/artifacts",
json!({
"ref": format!("other_pack.created_{}", uuid::Uuid::new_v4().simple()),
"scope": "pack",
"owner": "other_pack",
"type": "file_text",
"name": "Blocked Artifact"
}),
Some(&token),
)
.await
.expect("Failed to create blocked artifact");
assert_eq!(create_blocked.status(), StatusCode::FORBIDDEN);
}

View File

@@ -52,7 +52,7 @@ async fn setup_test_pack_and_action(pool: &PgPool) -> Result<(Pack, Action)> {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Action".to_string(), label: "Test Action".to_string(),
description: "Test action for SSE tests".to_string(), description: Some("Test action for SSE tests".to_string()),
entrypoint: "test.sh".to_string(), entrypoint: "test.sh".to_string(),
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,
@@ -75,6 +75,7 @@ async fn create_test_execution(pool: &PgPool, action_id: i64) -> Result<Executio
parent: None, parent: None,
enforcement: None, enforcement: None,
executor: None, executor: None,
worker: None,
status: ExecutionStatus::Scheduled, status: ExecutionStatus::Scheduled,
result: None, result: None,
workflow_task: None, workflow_task: None,
@@ -86,7 +87,7 @@ async fn create_test_execution(pool: &PgPool, action_id: i64) -> Result<Executio
/// Run with: cargo test test_sse_stream_receives_execution_updates -- --ignored --nocapture /// Run with: cargo test test_sse_stream_receives_execution_updates -- --ignored --nocapture
/// After starting: cargo run -p attune-api -- -c config.test.yaml /// After starting: cargo run -p attune-api -- -c config.test.yaml
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_sse_stream_receives_execution_updates() -> Result<()> { async fn test_sse_stream_receives_execution_updates() -> Result<()> {
// Set up test context with auth // Set up test context with auth
let ctx = TestContext::new().await?.with_auth().await?; let ctx = TestContext::new().await?.with_auth().await?;
@@ -225,7 +226,7 @@ async fn test_sse_stream_receives_execution_updates() -> Result<()> {
/// Test that SSE stream correctly filters by execution_id /// Test that SSE stream correctly filters by execution_id
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_sse_stream_filters_by_execution_id() -> Result<()> { async fn test_sse_stream_filters_by_execution_id() -> Result<()> {
// Set up test context with auth // Set up test context with auth
let ctx = TestContext::new().await?.with_auth().await?; let ctx = TestContext::new().await?.with_auth().await?;
@@ -327,7 +328,7 @@ async fn test_sse_stream_filters_by_execution_id() -> Result<()> {
} }
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_sse_stream_requires_authentication() -> Result<()> { async fn test_sse_stream_requires_authentication() -> Result<()> {
// Try to connect without token // Try to connect without token
let sse_url = "http://localhost:8080/api/v1/executions/stream"; let sse_url = "http://localhost:8080/api/v1/executions/stream";
@@ -373,7 +374,7 @@ async fn test_sse_stream_requires_authentication() -> Result<()> {
/// Test streaming all executions (no filter) /// Test streaming all executions (no filter)
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_sse_stream_all_executions() -> Result<()> { async fn test_sse_stream_all_executions() -> Result<()> {
// Set up test context with auth // Set up test context with auth
let ctx = TestContext::new().await?.with_auth().await?; let ctx = TestContext::new().await?.with_auth().await?;
@@ -466,7 +467,7 @@ async fn test_sse_stream_all_executions() -> Result<()> {
/// Test that PostgreSQL NOTIFY triggers actually fire /// Test that PostgreSQL NOTIFY triggers actually fire
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_postgresql_notify_trigger_fires() -> Result<()> { async fn test_postgresql_notify_trigger_fires() -> Result<()> {
let ctx = TestContext::new().await?; let ctx = TestContext::new().await?;

View File

@@ -108,7 +108,7 @@ async fn get_auth_token(app: &axum::Router, username: &str, password: &str) -> S
} }
#[tokio::test] #[tokio::test]
#[ignore] // Run with --ignored flag when database is available #[ignore = "integration test — requires database"]
async fn test_enable_webhook() { async fn test_enable_webhook() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -151,7 +151,7 @@ async fn test_enable_webhook() {
} }
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_disable_webhook() { async fn test_disable_webhook() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -202,7 +202,7 @@ async fn test_disable_webhook() {
} }
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_regenerate_webhook_key() { async fn test_regenerate_webhook_key() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -254,7 +254,7 @@ async fn test_regenerate_webhook_key() {
} }
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_regenerate_webhook_key_not_enabled() { async fn test_regenerate_webhook_key_not_enabled() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -291,7 +291,7 @@ async fn test_regenerate_webhook_key_not_enabled() {
} }
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_receive_webhook() { async fn test_receive_webhook() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -362,7 +362,7 @@ async fn test_receive_webhook() {
} }
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_receive_webhook_invalid_key() { async fn test_receive_webhook_invalid_key() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state)); let server = Server::new(std::sync::Arc::new(state));
@@ -392,7 +392,7 @@ async fn test_receive_webhook_invalid_key() {
} }
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_receive_webhook_disabled() { async fn test_receive_webhook_disabled() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -442,7 +442,7 @@ async fn test_receive_webhook_disabled() {
} }
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_webhook_requires_auth_for_management() { async fn test_webhook_requires_auth_for_management() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -475,7 +475,7 @@ async fn test_webhook_requires_auth_for_management() {
} }
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_receive_webhook_minimal_payload() { async fn test_receive_webhook_minimal_payload() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));

View File

@@ -122,7 +122,7 @@ fn generate_hmac_signature(payload: &[u8], secret: &str, algorithm: &str) -> Str
// ============================================================================ // ============================================================================
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_webhook_hmac_sha256_valid() { async fn test_webhook_hmac_sha256_valid() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -189,7 +189,7 @@ async fn test_webhook_hmac_sha256_valid() {
} }
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_webhook_hmac_sha512_valid() { async fn test_webhook_hmac_sha512_valid() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -246,7 +246,7 @@ async fn test_webhook_hmac_sha512_valid() {
} }
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_webhook_hmac_invalid_signature() { async fn test_webhook_hmac_invalid_signature() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -302,7 +302,7 @@ async fn test_webhook_hmac_invalid_signature() {
} }
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_webhook_hmac_missing_signature() { async fn test_webhook_hmac_missing_signature() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -355,7 +355,7 @@ async fn test_webhook_hmac_missing_signature() {
} }
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_webhook_hmac_wrong_secret() { async fn test_webhook_hmac_wrong_secret() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -418,7 +418,7 @@ async fn test_webhook_hmac_wrong_secret() {
// ============================================================================ // ============================================================================
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_webhook_rate_limit_enforced() { async fn test_webhook_rate_limit_enforced() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -494,7 +494,7 @@ async fn test_webhook_rate_limit_enforced() {
} }
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_webhook_rate_limit_disabled() { async fn test_webhook_rate_limit_disabled() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -541,7 +541,7 @@ async fn test_webhook_rate_limit_disabled() {
// ============================================================================ // ============================================================================
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_webhook_ip_whitelist_allowed() { async fn test_webhook_ip_whitelist_allowed() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -612,7 +612,7 @@ async fn test_webhook_ip_whitelist_allowed() {
} }
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_webhook_ip_whitelist_blocked() { async fn test_webhook_ip_whitelist_blocked() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -669,7 +669,7 @@ async fn test_webhook_ip_whitelist_blocked() {
// ============================================================================ // ============================================================================
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_webhook_payload_size_limit_enforced() { async fn test_webhook_payload_size_limit_enforced() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -720,7 +720,7 @@ async fn test_webhook_payload_size_limit_enforced() {
} }
#[tokio::test] #[tokio::test]
#[ignore] #[ignore = "integration test — requires database"]
async fn test_webhook_payload_size_within_limit() { async fn test_webhook_payload_size_within_limit() {
let state = setup_test_state().await; let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone())); let server = Server::new(std::sync::Arc::new(state.clone()));

View File

@@ -19,6 +19,7 @@ fn unique_pack_name() -> String {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_create_workflow_success() { async fn test_create_workflow_success() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -45,8 +46,7 @@ async fn test_create_workflow_success() {
} }
] ]
}, },
"tags": ["test", "automation"], "tags": ["test", "automation"]
"enabled": true
}), }),
ctx.token(), ctx.token(),
) )
@@ -59,11 +59,11 @@ async fn test_create_workflow_success() {
assert_eq!(body["data"]["ref"], "test-pack.test_workflow"); assert_eq!(body["data"]["ref"], "test-pack.test_workflow");
assert_eq!(body["data"]["label"], "Test Workflow"); assert_eq!(body["data"]["label"], "Test Workflow");
assert_eq!(body["data"]["version"], "1.0.0"); assert_eq!(body["data"]["version"], "1.0.0");
assert_eq!(body["data"]["enabled"], true);
assert!(body["data"]["tags"].as_array().unwrap().len() == 2); assert!(body["data"]["tags"].as_array().unwrap().len() == 2);
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_create_workflow_duplicate_ref() { async fn test_create_workflow_duplicate_ref() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -83,7 +83,6 @@ async fn test_create_workflow_duplicate_ref() {
out_schema: None, out_schema: None,
definition: json!({"tasks": []}), definition: json!({"tasks": []}),
tags: vec![], tags: vec![],
enabled: true,
}; };
WorkflowDefinitionRepository::create(&ctx.pool, input) WorkflowDefinitionRepository::create(&ctx.pool, input)
.await .await
@@ -109,6 +108,7 @@ async fn test_create_workflow_duplicate_ref() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_create_workflow_pack_not_found() { async fn test_create_workflow_pack_not_found() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -131,6 +131,7 @@ async fn test_create_workflow_pack_not_found() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_get_workflow_by_ref() { async fn test_get_workflow_by_ref() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -148,7 +149,6 @@ async fn test_get_workflow_by_ref() {
out_schema: None, out_schema: None,
definition: json!({"tasks": [{"name": "task1"}]}), definition: json!({"tasks": [{"name": "task1"}]}),
tags: vec!["test".to_string()], tags: vec!["test".to_string()],
enabled: true,
}; };
WorkflowDefinitionRepository::create(&ctx.pool, input) WorkflowDefinitionRepository::create(&ctx.pool, input)
.await .await
@@ -169,6 +169,7 @@ async fn test_get_workflow_by_ref() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_get_workflow_not_found() { async fn test_get_workflow_not_found() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -181,6 +182,7 @@ async fn test_get_workflow_not_found() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_list_workflows() { async fn test_list_workflows() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -200,7 +202,6 @@ async fn test_list_workflows() {
out_schema: None, out_schema: None,
definition: json!({"tasks": []}), definition: json!({"tasks": []}),
tags: vec!["test".to_string()], tags: vec!["test".to_string()],
enabled: i % 2 == 1, // Odd ones enabled
}; };
WorkflowDefinitionRepository::create(&ctx.pool, input) WorkflowDefinitionRepository::create(&ctx.pool, input)
.await .await
@@ -227,6 +228,7 @@ async fn test_list_workflows() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_list_workflows_by_pack() { async fn test_list_workflows_by_pack() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -249,7 +251,6 @@ async fn test_list_workflows_by_pack() {
out_schema: None, out_schema: None,
definition: json!({"tasks": []}), definition: json!({"tasks": []}),
tags: vec![], tags: vec![],
enabled: true,
}; };
WorkflowDefinitionRepository::create(&ctx.pool, input) WorkflowDefinitionRepository::create(&ctx.pool, input)
.await .await
@@ -268,7 +269,6 @@ async fn test_list_workflows_by_pack() {
out_schema: None, out_schema: None,
definition: json!({"tasks": []}), definition: json!({"tasks": []}),
tags: vec![], tags: vec![],
enabled: true,
}; };
WorkflowDefinitionRepository::create(&ctx.pool, input) WorkflowDefinitionRepository::create(&ctx.pool, input)
.await .await
@@ -294,20 +294,21 @@ async fn test_list_workflows_by_pack() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_list_workflows_with_filters() { async fn test_list_workflows_with_filters() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
let pack_name = unique_pack_name(); let pack_name = unique_pack_name();
let pack = create_test_pack(&ctx.pool, &pack_name).await.unwrap(); let pack = create_test_pack(&ctx.pool, &pack_name).await.unwrap();
// Create workflows with different tags and enabled status // Create workflows with different tags
let workflows = vec![ let workflows = vec![
("workflow1", vec!["incident", "approval"], true), ("workflow1", vec!["incident", "approval"]),
("workflow2", vec!["incident"], false), ("workflow2", vec!["incident"]),
("workflow3", vec!["automation"], true), ("workflow3", vec!["automation"]),
]; ];
for (ref_name, tags, enabled) in workflows { for (ref_name, tags) in workflows {
let input = CreateWorkflowDefinitionInput { let input = CreateWorkflowDefinitionInput {
r#ref: format!("test-pack.{}", ref_name), r#ref: format!("test-pack.{}", ref_name),
pack: pack.id, pack: pack.id,
@@ -319,24 +320,12 @@ async fn test_list_workflows_with_filters() {
out_schema: None, out_schema: None,
definition: json!({"tasks": []}), definition: json!({"tasks": []}),
tags: tags.iter().map(|s| s.to_string()).collect(), tags: tags.iter().map(|s| s.to_string()).collect(),
enabled,
}; };
WorkflowDefinitionRepository::create(&ctx.pool, input) WorkflowDefinitionRepository::create(&ctx.pool, input)
.await .await
.unwrap(); .unwrap();
} }
// Filter by enabled (and pack_ref for isolation)
let response = ctx
.get(
&format!("/api/v1/workflows?enabled=true&pack_ref={}", pack_name),
ctx.token(),
)
.await
.unwrap();
let body: Value = response.json().await.unwrap();
assert_eq!(body["data"].as_array().unwrap().len(), 2);
// Filter by tag (and pack_ref for isolation) // Filter by tag (and pack_ref for isolation)
let response = ctx let response = ctx
.get( .get(
@@ -361,6 +350,7 @@ async fn test_list_workflows_with_filters() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_update_workflow() { async fn test_update_workflow() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -378,7 +368,6 @@ async fn test_update_workflow() {
out_schema: None, out_schema: None,
definition: json!({"tasks": []}), definition: json!({"tasks": []}),
tags: vec!["test".to_string()], tags: vec!["test".to_string()],
enabled: true,
}; };
WorkflowDefinitionRepository::create(&ctx.pool, input) WorkflowDefinitionRepository::create(&ctx.pool, input)
.await .await
@@ -391,8 +380,7 @@ async fn test_update_workflow() {
json!({ json!({
"label": "Updated Label", "label": "Updated Label",
"description": "Updated description", "description": "Updated description",
"version": "1.1.0", "version": "1.1.0"
"enabled": false
}), }),
ctx.token(), ctx.token(),
) )
@@ -405,10 +393,10 @@ async fn test_update_workflow() {
assert_eq!(body["data"]["label"], "Updated Label"); assert_eq!(body["data"]["label"], "Updated Label");
assert_eq!(body["data"]["description"], "Updated description"); assert_eq!(body["data"]["description"], "Updated description");
assert_eq!(body["data"]["version"], "1.1.0"); assert_eq!(body["data"]["version"], "1.1.0");
assert_eq!(body["data"]["enabled"], false);
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_update_workflow_not_found() { async fn test_update_workflow_not_found() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -427,6 +415,7 @@ async fn test_update_workflow_not_found() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_delete_workflow() { async fn test_delete_workflow() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -444,7 +433,6 @@ async fn test_delete_workflow() {
out_schema: None, out_schema: None,
definition: json!({"tasks": []}), definition: json!({"tasks": []}),
tags: vec![], tags: vec![],
enabled: true,
}; };
WorkflowDefinitionRepository::create(&ctx.pool, input) WorkflowDefinitionRepository::create(&ctx.pool, input)
.await .await
@@ -468,6 +456,7 @@ async fn test_delete_workflow() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_delete_workflow_not_found() { async fn test_delete_workflow_not_found() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -480,6 +469,7 @@ async fn test_delete_workflow_not_found() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_create_workflow_requires_auth() { async fn test_create_workflow_requires_auth() {
let ctx = TestContext::new().await.unwrap(); let ctx = TestContext::new().await.unwrap();
@@ -504,6 +494,7 @@ async fn test_create_workflow_requires_auth() {
} }
#[tokio::test] #[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_workflow_validation() { async fn test_workflow_validation() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap(); let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();

View File

@@ -38,7 +38,7 @@ chrono = { workspace = true }
# Configuration # Configuration
config = { workspace = true } config = { workspace = true }
dirs = "5.0" dirs = "6.0"
# URL encoding # URL encoding
urlencoding = "2.1" urlencoding = "2.1"
@@ -51,14 +51,16 @@ flate2 = { workspace = true }
# WebSocket client (for notifier integration) # WebSocket client (for notifier integration)
tokio-tungstenite = { workspace = true } tokio-tungstenite = { workspace = true }
# Hashing
sha2 = { workspace = true }
# Terminal UI # Terminal UI
colored = "2.1" colored = "3.1"
comfy-table = "7.1" comfy-table = { version = "7.2", features = ["custom_styling"] }
indicatif = "0.17" dialoguer = "0.12"
dialoguer = "0.11"
# Authentication # Authentication
jsonwebtoken = { version = "10.2", features = ["rust_crypto"] } jsonwebtoken = { workspace = true }
# Logging # Logging
tracing = { workspace = true } tracing = { workspace = true }
@@ -67,7 +69,7 @@ tracing-subscriber = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = { workspace = true } tempfile = { workspace = true }
wiremock = "0.6" wiremock = "0.6"
assert_cmd = "2.0" assert_cmd = "2.2"
predicates = "3.0" predicates = "3.1"
mockito = "1.2" mockito = "1.7"
tokio-test = "0.4" tokio-test = "0.4"

View File

@@ -1,5 +1,5 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use reqwest::{multipart, Client as HttpClient, Method, RequestBuilder, StatusCode}; use reqwest::{header, multipart, Client as HttpClient, Method, RequestBuilder, StatusCode};
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
@@ -347,6 +347,80 @@ impl ApiClient {
.await .await
} }
/// GET request that returns raw bytes and optional filename from Content-Disposition.
///
/// Used for downloading binary content (e.g., artifact files).
/// Returns `(bytes, content_type, optional_filename)`.
pub async fn download_bytes(
&mut self,
path: &str,
) -> Result<(Vec<u8>, String, Option<String>)> {
// First attempt
let req = self.build_request(Method::GET, path);
let response = req.send().await.context("Failed to send request to API")?;
if response.status() == StatusCode::UNAUTHORIZED
&& self.refresh_token.is_some()
&& self.refresh_auth_token().await?
{
// Retry with new token
let req = self.build_request(Method::GET, path);
let response = req
.send()
.await
.context("Failed to send request to API (retry)")?;
return self.handle_bytes_response(response).await;
}
self.handle_bytes_response(response).await
}
/// Parse a binary response, extracting content type and optional filename.
async fn handle_bytes_response(
&self,
response: reqwest::Response,
) -> Result<(Vec<u8>, String, Option<String>)> {
let status = response.status();
if status.is_success() {
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let filename = response
.headers()
.get(header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.and_then(|v| {
// Parse filename from Content-Disposition: attachment; filename="name.ext"
v.split("filename=")
.nth(1)
.map(|f| f.trim_matches('"').trim_matches('\'').to_string())
});
let bytes = response
.bytes()
.await
.context("Failed to read response bytes")?;
Ok((bytes.to_vec(), content_type, filename))
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
if let Ok(api_error) = serde_json::from_str::<ApiError>(&error_text) {
anyhow::bail!("API error ({}): {}", status, api_error.error);
} else {
anyhow::bail!("API error ({}): {}", status, error_text);
}
}
}
/// POST a multipart/form-data request with a file field and optional text fields. /// POST a multipart/form-data request with a file field and optional text fields.
/// ///
/// - `file_field_name`: the multipart field name for the file /// - `file_field_name`: the multipart field name for the file

View File

@@ -90,7 +90,7 @@ struct Action {
action_ref: String, action_ref: String,
pack_ref: String, pack_ref: String,
label: String, label: String,
description: String, description: Option<String>,
entrypoint: String, entrypoint: String,
runtime: Option<i64>, runtime: Option<i64>,
created: String, created: String,
@@ -105,7 +105,7 @@ struct ActionDetail {
pack: i64, pack: i64,
pack_ref: String, pack_ref: String,
label: String, label: String,
description: String, description: Option<String>,
entrypoint: String, entrypoint: String,
runtime: Option<i64>, runtime: Option<i64>,
param_schema: Option<serde_json::Value>, param_schema: Option<serde_json::Value>,
@@ -241,7 +241,7 @@ async fn handle_list(
let mut table = output::create_table(); let mut table = output::create_table();
output::add_header( output::add_header(
&mut table, &mut table,
vec!["ID", "Pack", "Name", "Runner", "Enabled", "Description"], vec!["ID", "Pack", "Name", "Runner", "Description"],
); );
for action in actions { for action in actions {
@@ -253,8 +253,7 @@ async fn handle_list(
.runtime .runtime
.map(|r| r.to_string()) .map(|r| r.to_string())
.unwrap_or_else(|| "none".to_string()), .unwrap_or_else(|| "none".to_string()),
"".to_string(), output::truncate(&action.description.unwrap_or_default(), 40),
output::truncate(&action.description, 40),
]); ]);
} }
@@ -289,7 +288,10 @@ async fn handle_show(
("Reference", action.action_ref.clone()), ("Reference", action.action_ref.clone()),
("Pack", action.pack_ref.clone()), ("Pack", action.pack_ref.clone()),
("Label", action.label.clone()), ("Label", action.label.clone()),
("Description", action.description.clone()), (
"Description",
action.description.unwrap_or_else(|| "None".to_string()),
),
("Entry Point", action.entrypoint.clone()), ("Entry Point", action.entrypoint.clone()),
( (
"Runtime", "Runtime",
@@ -357,7 +359,10 @@ async fn handle_update(
("Ref", action.action_ref.clone()), ("Ref", action.action_ref.clone()),
("Pack", action.pack_ref.clone()), ("Pack", action.pack_ref.clone()),
("Label", action.label.clone()), ("Label", action.label.clone()),
("Description", action.description.clone()), (
"Description",
action.description.unwrap_or_else(|| "None".to_string()),
),
("Entrypoint", action.entrypoint.clone()), ("Entrypoint", action.entrypoint.clone()),
( (
"Runtime", "Runtime",

File diff suppressed because it is too large Load Diff

View File

@@ -175,7 +175,7 @@ async fn handle_current(output_format: OutputFormat) -> Result<()> {
match output_format { match output_format {
OutputFormat::Json | OutputFormat::Yaml => { OutputFormat::Json | OutputFormat::Yaml => {
let result = serde_json::json!({ let result = serde_json::json!({
"current_profile": config.current_profile "profile": config.current_profile
}); });
output::print_output(&result, output_format)?; output::print_output(&result, output_format)?;
} }
@@ -194,7 +194,7 @@ async fn handle_use(name: String, output_format: OutputFormat) -> Result<()> {
match output_format { match output_format {
OutputFormat::Json | OutputFormat::Yaml => { OutputFormat::Json | OutputFormat::Yaml => {
let result = serde_json::json!({ let result = serde_json::json!({
"current_profile": name, "profile": name,
"message": "Switched profile" "message": "Switched profile"
}); });
output::print_output(&result, output_format)?; output::print_output(&result, output_format)?;
@@ -299,10 +299,6 @@ async fn handle_show_profile(name: String, output_format: OutputFormat) -> Resul
), ),
]; ];
if let Some(output_format) = &profile.output_format {
pairs.push(("Output Format", output_format.clone()));
}
if let Some(description) = &profile.description { if let Some(description) = &profile.description {
pairs.push(("Description", description.clone())); pairs.push(("Description", description.clone()));
} }

View File

@@ -50,7 +50,7 @@ pub enum ExecutionCommands {
execution_id: i64, execution_id: i64,
/// Skip confirmation prompt /// Skip confirmation prompt
#[arg(short = 'y', long)] #[arg(long)]
yes: bool, yes: bool,
}, },
/// Get raw execution result /// Get raw execution result

View File

@@ -0,0 +1,605 @@
use anyhow::Result;
use clap::Subcommand;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};
use crate::client::ApiClient;
use crate::config::CliConfig;
use crate::output::{self, OutputFormat};
#[derive(Subcommand)]
pub enum KeyCommands {
/// List all keys (values redacted)
List {
/// Filter by owner type (system, identity, pack, action, sensor)
#[arg(long)]
owner_type: Option<String>,
/// Filter by owner string
#[arg(long)]
owner: Option<String>,
/// Page number
#[arg(long, default_value = "1")]
page: u32,
/// Items per page
#[arg(long, default_value = "50")]
per_page: u32,
},
/// Show details of a specific key
Show {
/// Key reference identifier
key_ref: String,
/// Decrypt and display the actual value (otherwise a SHA-256 hash is shown)
#[arg(short = 'd', long)]
decrypt: bool,
},
/// Create a new key/secret
Create {
/// Unique reference for the key (e.g., "github_token")
#[arg(long)]
r#ref: String,
/// Human-readable name for the key
#[arg(long)]
name: String,
/// The secret value to store. Plain strings are stored as JSON strings.
/// Use JSON syntax for structured values (e.g., '{"user":"admin","pass":"s3cret"}').
#[arg(long)]
value: String,
/// Owner type (system, identity, pack, action, sensor)
#[arg(long, default_value = "system")]
owner_type: String,
/// Owner string identifier
#[arg(long)]
owner: Option<String>,
/// Owner pack reference (auto-resolves pack ID)
#[arg(long)]
owner_pack_ref: Option<String>,
/// Owner action reference (auto-resolves action ID)
#[arg(long)]
owner_action_ref: Option<String>,
/// Owner sensor reference (auto-resolves sensor ID)
#[arg(long)]
owner_sensor_ref: Option<String>,
/// Encrypt the value before storing (default: unencrypted)
#[arg(short = 'e', long)]
encrypt: bool,
},
/// Update an existing key/secret
Update {
/// Key reference identifier
key_ref: String,
/// Update the human-readable name
#[arg(long)]
name: Option<String>,
/// Update the secret value. Plain strings are stored as JSON strings.
/// Use JSON syntax for structured values (e.g., '{"user":"admin","pass":"s3cret"}').
#[arg(long)]
value: Option<String>,
/// Update encryption status
#[arg(long)]
encrypted: Option<bool>,
},
/// Delete a key/secret
Delete {
/// Key reference identifier
key_ref: String,
/// Skip confirmation prompt
#[arg(long)]
yes: bool,
},
}
// ── Response / request types used for (de)serialization against the API ────
#[derive(Debug, Serialize, Deserialize)]
struct KeyResponse {
id: i64,
#[serde(rename = "ref")]
key_ref: String,
owner_type: String,
#[serde(default)]
owner: Option<String>,
#[serde(default)]
owner_identity: Option<i64>,
#[serde(default)]
owner_pack: Option<i64>,
#[serde(default)]
owner_pack_ref: Option<String>,
#[serde(default)]
owner_action: Option<i64>,
#[serde(default)]
owner_action_ref: Option<String>,
#[serde(default)]
owner_sensor: Option<i64>,
#[serde(default)]
owner_sensor_ref: Option<String>,
name: String,
encrypted: bool,
#[serde(default)]
value: JsonValue,
created: String,
updated: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct KeySummary {
id: i64,
#[serde(rename = "ref")]
key_ref: String,
owner_type: String,
#[serde(default)]
owner: Option<String>,
name: String,
encrypted: bool,
created: String,
}
#[derive(Debug, Serialize)]
struct CreateKeyRequestBody {
r#ref: String,
owner_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
owner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
owner_pack_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
owner_action_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
owner_sensor_ref: Option<String>,
name: String,
value: JsonValue,
encrypted: bool,
}
#[derive(Debug, Serialize)]
struct UpdateKeyRequestBody {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<JsonValue>,
#[serde(skip_serializing_if = "Option::is_none")]
encrypted: Option<bool>,
}
// ── Command dispatch ───────────────────────────────────────────────────────
pub async fn handle_key_command(
profile: &Option<String>,
command: KeyCommands,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
match command {
KeyCommands::List {
owner_type,
owner,
page,
per_page,
} => {
handle_list(
profile,
owner_type,
owner,
page,
per_page,
api_url,
output_format,
)
.await
}
KeyCommands::Show { key_ref, decrypt } => {
handle_show(profile, key_ref, decrypt, api_url, output_format).await
}
KeyCommands::Create {
r#ref,
name,
value,
owner_type,
owner,
owner_pack_ref,
owner_action_ref,
owner_sensor_ref,
encrypt,
} => {
handle_create(
profile,
r#ref,
name,
value,
owner_type,
owner,
owner_pack_ref,
owner_action_ref,
owner_sensor_ref,
encrypt,
api_url,
output_format,
)
.await
}
KeyCommands::Update {
key_ref,
name,
value,
encrypted,
} => {
handle_update(
profile,
key_ref,
name,
value,
encrypted,
api_url,
output_format,
)
.await
}
KeyCommands::Delete { key_ref, yes } => {
handle_delete(profile, key_ref, yes, api_url, output_format).await
}
}
}
// ── Handlers ───────────────────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
async fn handle_list(
profile: &Option<String>,
owner_type: Option<String>,
owner: Option<String>,
page: u32,
per_page: u32,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let mut query_params = vec![format!("page={}", page), format!("per_page={}", per_page)];
if let Some(ot) = owner_type {
query_params.push(format!("owner_type={}", ot));
}
if let Some(o) = owner {
query_params.push(format!("owner={}", o));
}
let path = format!("/keys?{}", query_params.join("&"));
let keys: Vec<KeySummary> = client.get(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&keys, output_format)?;
}
OutputFormat::Table => {
if keys.is_empty() {
output::print_info("No keys found");
} else {
let mut table = output::create_table();
output::add_header(
&mut table,
vec![
"ID",
"Ref",
"Name",
"Owner Type",
"Owner",
"Encrypted",
"Created",
],
);
for key in keys {
table.add_row(vec![
key.id.to_string(),
key.key_ref.clone(),
key.name.clone(),
key.owner_type.clone(),
key.owner.clone().unwrap_or_else(|| "-".to_string()),
output::format_bool(key.encrypted),
output::format_timestamp(&key.created),
]);
}
println!("{}", table);
}
}
}
Ok(())
}
async fn handle_show(
profile: &Option<String>,
key_ref: String,
decrypt: bool,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let path = format!("/keys/{}", urlencoding::encode(&key_ref));
let key: KeyResponse = client.get(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
if decrypt {
output::print_output(&key, output_format)?;
} else {
// Redact value — replace with hash
let mut redacted = serde_json::to_value(&key)?;
if let Some(obj) = redacted.as_object_mut() {
obj.insert(
"value".to_string(),
JsonValue::String(hash_value_for_display(&key.value)),
);
}
output::print_output(&redacted, output_format)?;
}
}
OutputFormat::Table => {
output::print_section(&format!("Key: {}", key.key_ref));
let mut pairs = vec![
("ID", key.id.to_string()),
("Reference", key.key_ref.clone()),
("Name", key.name.clone()),
("Owner Type", key.owner_type.clone()),
(
"Owner",
key.owner.clone().unwrap_or_else(|| "-".to_string()),
),
];
if let Some(ref pack_ref) = key.owner_pack_ref {
pairs.push(("Owner Pack", pack_ref.clone()));
}
if let Some(ref action_ref) = key.owner_action_ref {
pairs.push(("Owner Action", action_ref.clone()));
}
if let Some(ref sensor_ref) = key.owner_sensor_ref {
pairs.push(("Owner Sensor", sensor_ref.clone()));
}
pairs.push(("Encrypted", output::format_bool(key.encrypted)));
if decrypt {
pairs.push(("Value", format_value_for_display(&key.value)));
} else {
pairs.push(("Value (SHA-256)", hash_value_for_display(&key.value)));
pairs.push((
"",
"(use --decrypt / -d to reveal the actual value)".to_string(),
));
}
pairs.push(("Created", output::format_timestamp(&key.created)));
pairs.push(("Updated", output::format_timestamp(&key.updated)));
output::print_key_value_table(pairs);
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn handle_create(
profile: &Option<String>,
key_ref: String,
name: String,
value: String,
owner_type: String,
owner: Option<String>,
owner_pack_ref: Option<String>,
owner_action_ref: Option<String>,
owner_sensor_ref: Option<String>,
encrypted: bool,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
// Validate owner_type before sending
validate_owner_type(&owner_type)?;
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let json_value = parse_value_as_json(&value);
let request = CreateKeyRequestBody {
r#ref: key_ref,
owner_type,
owner,
owner_pack_ref,
owner_action_ref,
owner_sensor_ref,
name,
value: json_value,
encrypted,
};
let key: KeyResponse = client.post("/keys", &request).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&key, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!("Key '{}' created successfully", key.key_ref));
output::print_key_value_table(vec![
("ID", key.id.to_string()),
("Reference", key.key_ref.clone()),
("Name", key.name.clone()),
("Owner Type", key.owner_type.clone()),
(
"Owner",
key.owner.clone().unwrap_or_else(|| "-".to_string()),
),
("Encrypted", output::format_bool(key.encrypted)),
("Created", output::format_timestamp(&key.created)),
]);
}
}
Ok(())
}
async fn handle_update(
profile: &Option<String>,
key_ref: String,
name: Option<String>,
value: Option<String>,
encrypted: Option<bool>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
if name.is_none() && value.is_none() && encrypted.is_none() {
anyhow::bail!(
"At least one field must be provided to update (--name, --value, or --encrypted)"
);
}
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let json_value = value.map(|v| parse_value_as_json(&v));
let request = UpdateKeyRequestBody {
name,
value: json_value,
encrypted,
};
let path = format!("/keys/{}", urlencoding::encode(&key_ref));
let key: KeyResponse = client.put(&path, &request).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&key, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!("Key '{}' updated successfully", key.key_ref));
output::print_key_value_table(vec![
("ID", key.id.to_string()),
("Reference", key.key_ref.clone()),
("Name", key.name.clone()),
("Owner Type", key.owner_type.clone()),
(
"Owner",
key.owner.clone().unwrap_or_else(|| "-".to_string()),
),
("Encrypted", output::format_bool(key.encrypted)),
("Updated", output::format_timestamp(&key.updated)),
]);
}
}
Ok(())
}
async fn handle_delete(
profile: &Option<String>,
key_ref: String,
yes: bool,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
// Confirm deletion unless --yes is provided
if !yes && matches!(output_format, OutputFormat::Table) {
let confirm = dialoguer::Confirm::new()
.with_prompt(format!(
"Are you sure you want to delete key '{}'?",
key_ref
))
.default(false)
.interact()?;
if !confirm {
output::print_info("Deletion cancelled");
return Ok(());
}
}
let path = format!("/keys/{}", urlencoding::encode(&key_ref));
client.delete_no_response(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
let msg =
serde_json::json!({"message": format!("Key '{}' deleted successfully", key_ref)});
output::print_output(&msg, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!("Key '{}' deleted successfully", key_ref));
}
}
Ok(())
}
// ── Helpers ────────────────────────────────────────────────────────────────
/// Validate that the owner_type string is one of the accepted values.
fn validate_owner_type(owner_type: &str) -> Result<()> {
const VALID: &[&str] = &["system", "identity", "pack", "action", "sensor"];
if !VALID.contains(&owner_type) {
anyhow::bail!(
"Invalid owner type '{}'. Must be one of: {}",
owner_type,
VALID.join(", ")
);
}
Ok(())
}
/// Parse a CLI string value into a [`JsonValue`].
///
/// If the input is valid JSON (object, array, number, boolean, null, or
/// quoted string), it is used as-is. Otherwise, it is treated as a plain
/// string and wrapped in a JSON string value.
fn parse_value_as_json(input: &str) -> JsonValue {
match serde_json::from_str::<JsonValue>(input) {
Ok(v) => v,
Err(_) => JsonValue::String(input.to_string()),
}
}
/// Format a [`JsonValue`] for table display.
fn format_value_for_display(value: &JsonValue) -> String {
match value {
JsonValue::String(s) => s.clone(),
other => serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()),
}
}
/// Compute a SHA-256 hash of the JSON value for display purposes.
///
/// This lets users verify a value matches expectations without revealing
/// the actual content (e.g., to confirm it hasn't changed).
fn hash_value_for_display(value: &JsonValue) -> String {
let serialized = serde_json::to_string(value).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(serialized.as_bytes());
let result = hasher.finalize();
format!("sha256:{:x}", result)
}

View File

@@ -1,7 +1,9 @@
pub mod action; pub mod action;
pub mod artifact;
pub mod auth; pub mod auth;
pub mod config; pub mod config;
pub mod execution; pub mod execution;
pub mod key;
pub mod pack; pub mod pack;
pub mod pack_index; pub mod pack_index;
pub mod rule; pub mod rule;

View File

@@ -95,10 +95,6 @@ pub enum PackCommands {
/// Update version /// Update version
#[arg(long)] #[arg(long)]
version: Option<String>, version: Option<String>,
/// Update enabled status
#[arg(long)]
enabled: Option<bool>,
}, },
/// Uninstall a pack /// Uninstall a pack
Uninstall { Uninstall {
@@ -246,8 +242,6 @@ struct Pack {
#[serde(default)] #[serde(default)]
keywords: Option<Vec<String>>, keywords: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
enabled: Option<bool>,
#[serde(default)]
metadata: Option<serde_json::Value>, metadata: Option<serde_json::Value>,
created: String, created: String,
updated: String, updated: String,
@@ -273,8 +267,6 @@ struct PackDetail {
#[serde(default)] #[serde(default)]
keywords: Option<Vec<String>>, keywords: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
enabled: Option<bool>,
#[serde(default)]
metadata: Option<serde_json::Value>, metadata: Option<serde_json::Value>,
created: String, created: String,
updated: String, updated: String,
@@ -404,7 +396,6 @@ pub async fn handle_pack_command(
label, label,
description, description,
version, version,
enabled,
} => { } => {
handle_update( handle_update(
profile, profile,
@@ -412,7 +403,6 @@ pub async fn handle_pack_command(
label, label,
description, description,
version, version,
enabled,
api_url, api_url,
output_format, output_format,
) )
@@ -651,17 +641,13 @@ async fn handle_list(
output::print_info("No packs found"); output::print_info("No packs found");
} else { } else {
let mut table = output::create_table(); let mut table = output::create_table();
output::add_header( output::add_header(&mut table, vec!["ID", "Name", "Version", "Description"]);
&mut table,
vec!["ID", "Name", "Version", "Enabled", "Description"],
);
for pack in packs { for pack in packs {
table.add_row(vec![ table.add_row(vec![
pack.id.to_string(), pack.id.to_string(),
pack.pack_ref, pack.pack_ref,
pack.version, pack.version,
output::format_bool(pack.enabled.unwrap_or(true)),
output::truncate(&pack.description.unwrap_or_default(), 50), output::truncate(&pack.description.unwrap_or_default(), 50),
]); ]);
} }
@@ -705,7 +691,6 @@ async fn handle_show(
"Description", "Description",
pack.description.unwrap_or_else(|| "None".to_string()), pack.description.unwrap_or_else(|| "None".to_string()),
), ),
("Enabled", output::format_bool(pack.enabled.unwrap_or(true))),
("Actions", pack.action_count.unwrap_or(0).to_string()), ("Actions", pack.action_count.unwrap_or(0).to_string()),
("Triggers", pack.trigger_count.unwrap_or(0).to_string()), ("Triggers", pack.trigger_count.unwrap_or(0).to_string()),
("Rules", pack.rule_count.unwrap_or(0).to_string()), ("Rules", pack.rule_count.unwrap_or(0).to_string()),
@@ -1779,7 +1764,6 @@ async fn handle_update(
label: Option<String>, label: Option<String>,
description: Option<String>, description: Option<String>,
version: Option<String>, version: Option<String>,
enabled: Option<bool>,
api_url: &Option<String>, api_url: &Option<String>,
output_format: OutputFormat, output_format: OutputFormat,
) -> Result<()> { ) -> Result<()> {
@@ -1787,27 +1771,30 @@ async fn handle_update(
let mut client = ApiClient::from_config(&config, api_url); let mut client = ApiClient::from_config(&config, api_url);
// Check that at least one field is provided // Check that at least one field is provided
if label.is_none() && description.is_none() && version.is_none() && enabled.is_none() { if label.is_none() && description.is_none() && version.is_none() {
anyhow::bail!("At least one field must be provided to update"); anyhow::bail!("At least one field must be provided to update");
} }
#[derive(Serialize)]
#[serde(tag = "op", content = "value", rename_all = "snake_case")]
enum PackDescriptionPatch {
Set(String),
}
#[derive(Serialize)] #[derive(Serialize)]
struct UpdatePackRequest { struct UpdatePackRequest {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
label: Option<String>, label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>, description: Option<PackDescriptionPatch>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
version: Option<String>, version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
enabled: Option<bool>,
} }
let request = UpdatePackRequest { let request = UpdatePackRequest {
label, label,
description, description: description.map(PackDescriptionPatch::Set),
version, version,
enabled,
}; };
let path = format!("/packs/{}", pack_ref); let path = format!("/packs/{}", pack_ref);
@@ -1824,7 +1811,6 @@ async fn handle_update(
("Ref", pack.pack_ref.clone()), ("Ref", pack.pack_ref.clone()),
("Label", pack.label.clone()), ("Label", pack.label.clone()),
("Version", pack.version.clone()), ("Version", pack.version.clone()),
("Enabled", output::format_bool(pack.enabled.unwrap_or(true))),
("Updated", output::format_timestamp(&pack.updated)), ("Updated", output::format_timestamp(&pack.updated)),
]); ]);
} }

View File

@@ -98,7 +98,7 @@ pub enum RuleCommands {
rule_ref: String, rule_ref: String,
/// Skip confirmation prompt /// Skip confirmation prompt
#[arg(short = 'y', long)] #[arg(long)]
yes: bool, yes: bool,
}, },
} }
@@ -112,7 +112,7 @@ struct Rule {
pack: Option<i64>, pack: Option<i64>,
pack_ref: String, pack_ref: String,
label: String, label: String,
description: String, description: Option<String>,
#[serde(default)] #[serde(default)]
trigger: Option<i64>, trigger: Option<i64>,
trigger_ref: String, trigger_ref: String,
@@ -133,7 +133,7 @@ struct RuleDetail {
pack: Option<i64>, pack: Option<i64>,
pack_ref: String, pack_ref: String,
label: String, label: String,
description: String, description: Option<String>,
#[serde(default)] #[serde(default)]
trigger: Option<i64>, trigger: Option<i64>,
trigger_ref: String, trigger_ref: String,
@@ -275,12 +275,13 @@ async fn handle_list(
let mut table = output::create_table(); let mut table = output::create_table();
output::add_header( output::add_header(
&mut table, &mut table,
vec!["ID", "Pack", "Name", "Trigger", "Action", "Enabled"], vec!["ID", "Ref", "Pack", "Label", "Trigger", "Action", "Enabled"],
); );
for rule in rules { for rule in rules {
table.add_row(vec![ table.add_row(vec![
rule.id.to_string(), rule.id.to_string(),
rule.rule_ref.clone(),
rule.pack_ref.clone(), rule.pack_ref.clone(),
rule.label.clone(), rule.label.clone(),
rule.trigger_ref.clone(), rule.trigger_ref.clone(),
@@ -320,7 +321,10 @@ async fn handle_show(
("Ref", rule.rule_ref.clone()), ("Ref", rule.rule_ref.clone()),
("Pack", rule.pack_ref.clone()), ("Pack", rule.pack_ref.clone()),
("Label", rule.label.clone()), ("Label", rule.label.clone()),
("Description", rule.description.clone()), (
"Description",
rule.description.unwrap_or_else(|| "None".to_string()),
),
("Trigger", rule.trigger_ref.clone()), ("Trigger", rule.trigger_ref.clone()),
("Action", rule.action_ref.clone()), ("Action", rule.action_ref.clone()),
("Enabled", output::format_bool(rule.enabled)), ("Enabled", output::format_bool(rule.enabled)),
@@ -439,7 +443,10 @@ async fn handle_update(
("Ref", rule.rule_ref.clone()), ("Ref", rule.rule_ref.clone()),
("Pack", rule.pack_ref.clone()), ("Pack", rule.pack_ref.clone()),
("Label", rule.label.clone()), ("Label", rule.label.clone()),
("Description", rule.description.clone()), (
"Description",
rule.description.unwrap_or_else(|| "None".to_string()),
),
("Trigger", rule.trigger_ref.clone()), ("Trigger", rule.trigger_ref.clone()),
("Action", rule.action_ref.clone()), ("Action", rule.action_ref.clone()),
("Enabled", output::format_bool(rule.enabled)), ("Enabled", output::format_bool(rule.enabled)),

View File

@@ -254,19 +254,25 @@ async fn handle_update(
anyhow::bail!("At least one field must be provided to update"); anyhow::bail!("At least one field must be provided to update");
} }
#[derive(Serialize)]
#[serde(tag = "op", content = "value", rename_all = "snake_case")]
enum TriggerDescriptionPatch {
Set(String),
}
#[derive(Serialize)] #[derive(Serialize)]
struct UpdateTriggerRequest { struct UpdateTriggerRequest {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
label: Option<String>, label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>, description: Option<TriggerDescriptionPatch>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
enabled: Option<bool>, enabled: Option<bool>,
} }
let request = UpdateTriggerRequest { let request = UpdateTriggerRequest {
label, label,
description, description: description.map(TriggerDescriptionPatch::Set),
enabled, enabled,
}; };

View File

@@ -85,10 +85,6 @@ struct ActionYaml {
/// Tags /// Tags
#[serde(default)] #[serde(default)]
tags: Option<Vec<String>>, tags: Option<Vec<String>>,
/// Whether the action is enabled
#[serde(default)]
enabled: Option<bool>,
} }
// ── API DTOs ──────────────────────────────────────────────────────────── // ── API DTOs ────────────────────────────────────────────────────────────
@@ -109,8 +105,6 @@ struct SaveWorkflowFileRequest {
out_schema: Option<serde_json::Value>, out_schema: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
tags: Option<Vec<String>>, tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
enabled: Option<bool>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -127,7 +121,6 @@ struct WorkflowResponse {
out_schema: Option<serde_json::Value>, out_schema: Option<serde_json::Value>,
definition: serde_json::Value, definition: serde_json::Value,
tags: Vec<String>, tags: Vec<String>,
enabled: bool,
created: String, created: String,
updated: String, updated: String,
} }
@@ -142,7 +135,6 @@ struct WorkflowSummary {
description: Option<String>, description: Option<String>,
version: String, version: String,
tags: Vec<String>, tags: Vec<String>,
enabled: bool,
created: String, created: String,
updated: String, updated: String,
} }
@@ -281,7 +273,6 @@ async fn handle_upload(
param_schema: action.parameters.clone(), param_schema: action.parameters.clone(),
out_schema: action.output.clone(), out_schema: action.output.clone(),
tags: action.tags.clone(), tags: action.tags.clone(),
enabled: action.enabled,
}; };
// ── 6. Print progress ─────────────────────────────────────────────── // ── 6. Print progress ───────────────────────────────────────────────
@@ -357,7 +348,6 @@ async fn handle_upload(
response.tags.join(", ") response.tags.join(", ")
}, },
), ),
("Enabled", output::format_bool(response.enabled)),
]); ]);
} }
} }
@@ -408,15 +398,7 @@ async fn handle_list(
let mut table = output::create_table(); let mut table = output::create_table();
output::add_header( output::add_header(
&mut table, &mut table,
vec![ vec!["ID", "Reference", "Pack", "Label", "Version", "Tags"],
"ID",
"Reference",
"Pack",
"Label",
"Version",
"Enabled",
"Tags",
],
); );
for wf in &workflows { for wf in &workflows {
@@ -426,7 +408,6 @@ async fn handle_list(
wf.pack_ref.clone(), wf.pack_ref.clone(),
output::truncate(&wf.label, 30), output::truncate(&wf.label, 30),
wf.version.clone(), wf.version.clone(),
output::format_bool(wf.enabled),
if wf.tags.is_empty() { if wf.tags.is_empty() {
"-".to_string() "-".to_string()
} else { } else {
@@ -478,7 +459,6 @@ async fn handle_show(
.unwrap_or_else(|| "-".to_string()), .unwrap_or_else(|| "-".to_string()),
), ),
("Version", workflow.version.clone()), ("Version", workflow.version.clone()),
("Enabled", output::format_bool(workflow.enabled)),
( (
"Tags", "Tags",
if workflow.tags.is_empty() { if workflow.tags.is_empty() {

View File

@@ -5,25 +5,35 @@ use std::env;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use crate::output::OutputFormat;
/// CLI configuration stored in user's home directory /// CLI configuration stored in user's home directory
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliConfig { pub struct CliConfig {
/// Current active profile name /// Current active profile name
#[serde(default = "default_profile_name")] #[serde(
default = "default_profile_name",
rename = "profile",
alias = "current_profile"
)]
pub current_profile: String, pub current_profile: String,
/// Named profiles (like SSH hosts) /// Named profiles (like SSH hosts)
#[serde(default)] #[serde(default)]
pub profiles: HashMap<String, Profile>, pub profiles: HashMap<String, Profile>,
/// Default output format (can be overridden per-profile) /// Output format (table, json, yaml)
#[serde(default = "default_output_format")] #[serde(
pub default_output_format: String, default = "default_format",
rename = "format",
alias = "default_output_format"
)]
pub format: String,
} }
fn default_profile_name() -> String { fn default_profile_name() -> String {
"default".to_string() "default".to_string()
} }
fn default_output_format() -> String { fn default_format() -> String {
"table".to_string() "table".to_string()
} }
@@ -38,8 +48,9 @@ pub struct Profile {
/// Refresh token /// Refresh token
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>, pub refresh_token: Option<String>,
/// Output format override for this profile /// Output format override for this profile (deprecated — ignored, kept for deserialization compat)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing)]
#[allow(dead_code)]
pub output_format: Option<String>, pub output_format: Option<String>,
/// Optional description /// Optional description
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -63,7 +74,7 @@ impl Default for CliConfig {
Self { Self {
current_profile: "default".to_string(), current_profile: "default".to_string(),
profiles, profiles,
default_output_format: default_output_format(), format: default_format(),
} }
} }
} }
@@ -193,6 +204,29 @@ impl CliConfig {
self.save() self.save()
} }
/// Resolve the effective output format.
///
/// Priority (highest to lowest):
/// 1. Explicit CLI flag (`--json`, `--yaml`, `--output`)
/// 2. Config `format` field
///
/// The `cli_flag` parameter should be `None` when the user did not pass an
/// explicit flag (i.e. clap returned the default value `table` *without*
/// the user typing it). Callers should pass `Some(format)` only when the
/// user actually supplied the flag.
pub fn effective_format(&self, cli_override: Option<OutputFormat>) -> OutputFormat {
if let Some(fmt) = cli_override {
return fmt;
}
// Fall back to config value
match self.format.to_lowercase().as_str() {
"json" => OutputFormat::Json,
"yaml" => OutputFormat::Yaml,
_ => OutputFormat::Table,
}
}
/// Set a configuration value by key /// Set a configuration value by key
pub fn set_value(&mut self, key: &str, value: String) -> Result<()> { pub fn set_value(&mut self, key: &str, value: String) -> Result<()> {
match key { match key {
@@ -200,14 +234,18 @@ impl CliConfig {
let profile = self.current_profile_mut()?; let profile = self.current_profile_mut()?;
profile.api_url = value; profile.api_url = value;
} }
"output_format" => { "format" | "output_format" | "default_output_format" => {
let profile = self.current_profile_mut()?; // Validate the value
profile.output_format = Some(value); match value.to_lowercase().as_str() {
"table" | "json" | "yaml" => {}
_ => anyhow::bail!(
"Invalid format '{}'. Must be one of: table, json, yaml",
value
),
}
self.format = value.to_lowercase();
} }
"default_output_format" => { "profile" | "current_profile" => {
self.default_output_format = value;
}
"current_profile" => {
self.switch_profile(value)?; self.switch_profile(value)?;
return Ok(()); return Ok(());
} }
@@ -223,15 +261,8 @@ impl CliConfig {
let profile = self.current_profile()?; let profile = self.current_profile()?;
Ok(profile.api_url.clone()) Ok(profile.api_url.clone())
} }
"output_format" => { "format" | "output_format" | "default_output_format" => Ok(self.format.clone()),
let profile = self.current_profile()?; "profile" | "current_profile" => Ok(self.current_profile.clone()),
Ok(profile
.output_format
.clone()
.unwrap_or_else(|| self.default_output_format.clone()))
}
"default_output_format" => Ok(self.default_output_format.clone()),
"current_profile" => Ok(self.current_profile.clone()),
"auth_token" => { "auth_token" => {
let profile = self.current_profile()?; let profile = self.current_profile()?;
Ok(profile Ok(profile
@@ -262,19 +293,9 @@ impl CliConfig {
}; };
vec![ vec![
("current_profile".to_string(), self.current_profile.clone()), ("profile".to_string(), self.current_profile.clone()),
("format".to_string(), self.format.clone()),
("api_url".to_string(), profile.api_url.clone()), ("api_url".to_string(), profile.api_url.clone()),
(
"output_format".to_string(),
profile
.output_format
.clone()
.unwrap_or_else(|| self.default_output_format.clone()),
),
(
"default_output_format".to_string(),
self.default_output_format.clone(),
),
( (
"auth_token".to_string(), "auth_token".to_string(),
profile profile
@@ -354,7 +375,7 @@ mod tests {
fn test_default_config() { fn test_default_config() {
let config = CliConfig::default(); let config = CliConfig::default();
assert_eq!(config.current_profile, "default"); assert_eq!(config.current_profile, "default");
assert_eq!(config.default_output_format, "table"); assert_eq!(config.format, "table");
assert!(config.profiles.contains_key("default")); assert!(config.profiles.contains_key("default"));
let profile = config.current_profile().unwrap(); let profile = config.current_profile().unwrap();
@@ -378,6 +399,37 @@ mod tests {
); );
} }
#[test]
fn test_effective_format_defaults_to_config() {
let config = CliConfig {
format: "json".to_string(),
..Default::default()
};
// No CLI override → uses config
assert_eq!(config.effective_format(None), OutputFormat::Json);
}
#[test]
fn test_effective_format_cli_overrides_config() {
let config = CliConfig {
format: "json".to_string(),
..Default::default()
};
// CLI override wins
assert_eq!(
config.effective_format(Some(OutputFormat::Yaml)),
OutputFormat::Yaml
);
}
#[test]
fn test_effective_format_default_table() {
let config = CliConfig::default();
assert_eq!(config.effective_format(None), OutputFormat::Table);
}
#[test] #[test]
fn test_profile_management() { fn test_profile_management() {
let mut config = CliConfig::default(); let mut config = CliConfig::default();
@@ -387,7 +439,7 @@ mod tests {
api_url: "https://staging.example.com".to_string(), api_url: "https://staging.example.com".to_string(),
auth_token: None, auth_token: None,
refresh_token: None, refresh_token: None,
output_format: Some("json".to_string()), output_format: None,
description: Some("Staging environment".to_string()), description: Some("Staging environment".to_string()),
}; };
config config
@@ -442,7 +494,7 @@ mod tests {
config.get_value("api_url").unwrap(), config.get_value("api_url").unwrap(),
"http://localhost:8080" "http://localhost:8080"
); );
assert_eq!(config.get_value("output_format").unwrap(), "table"); assert_eq!(config.get_value("format").unwrap(), "table");
// Set API URL for current profile // Set API URL for current profile
config config
@@ -450,10 +502,53 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(config.get_value("api_url").unwrap(), "http://test.com"); assert_eq!(config.get_value("api_url").unwrap(), "http://test.com");
// Set output format for current profile // Set format
config config.set_value("format", "json".to_string()).unwrap();
assert_eq!(config.get_value("format").unwrap(), "json");
}
#[test]
fn test_set_value_validates_format() {
let mut config = CliConfig::default();
// Valid values
assert!(config.set_value("format", "table".to_string()).is_ok());
assert!(config.set_value("format", "json".to_string()).is_ok());
assert!(config.set_value("format", "yaml".to_string()).is_ok());
assert!(config.set_value("format", "JSON".to_string()).is_ok()); // case-insensitive
// Invalid value
assert!(config.set_value("format", "xml".to_string()).is_err());
}
#[test]
fn test_backward_compat_aliases() {
let mut config = CliConfig::default();
// Old key names should still work for get/set
assert!(config
.set_value("output_format", "json".to_string()) .set_value("output_format", "json".to_string())
.unwrap(); .is_ok());
assert_eq!(config.get_value("output_format").unwrap(), "json"); assert_eq!(config.get_value("output_format").unwrap(), "json");
assert_eq!(config.get_value("format").unwrap(), "json");
assert!(config
.set_value("default_output_format", "yaml".to_string())
.is_ok());
assert_eq!(config.get_value("default_output_format").unwrap(), "yaml");
assert_eq!(config.get_value("format").unwrap(), "yaml");
}
#[test]
fn test_deserialize_legacy_default_output_format() {
let yaml = r#"
profile: default
default_output_format: json
profiles:
default:
api_url: http://localhost:8080
"#;
let config: CliConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(config.format, "json");
} }
} }

View File

@@ -9,9 +9,11 @@ mod wait;
use commands::{ use commands::{
action::{handle_action_command, ActionCommands}, action::{handle_action_command, ActionCommands},
artifact::ArtifactCommands,
auth::AuthCommands, auth::AuthCommands,
config::ConfigCommands, config::ConfigCommands,
execution::ExecutionCommands, execution::ExecutionCommands,
key::KeyCommands,
pack::PackCommands, pack::PackCommands,
rule::RuleCommands, rule::RuleCommands,
sensor::SensorCommands, sensor::SensorCommands,
@@ -33,8 +35,8 @@ struct Cli {
api_url: Option<String>, api_url: Option<String>,
/// Output format /// Output format
#[arg(long, value_enum, default_value = "table", global = true, conflicts_with_all = ["json", "yaml"])] #[arg(long, value_enum, global = true, conflicts_with_all = ["json", "yaml"])]
output: output::OutputFormat, output: Option<output::OutputFormat>,
/// Output as JSON (shorthand for --output json) /// Output as JSON (shorthand for --output json)
#[arg(short = 'j', long, global = true, conflicts_with_all = ["output", "yaml"])] #[arg(short = 'j', long, global = true, conflicts_with_all = ["output", "yaml"])]
@@ -74,6 +76,11 @@ enum Commands {
#[command(subcommand)] #[command(subcommand)]
command: RuleCommands, command: RuleCommands,
}, },
/// Key/secret management
Key {
#[command(subcommand)]
command: KeyCommands,
},
/// Execution monitoring /// Execution monitoring
Execution { Execution {
#[command(subcommand)] #[command(subcommand)]
@@ -94,6 +101,11 @@ enum Commands {
#[command(subcommand)] #[command(subcommand)]
command: SensorCommands, command: SensorCommands,
}, },
/// Artifact management (list, upload, download, delete)
Artifact {
#[command(subcommand)]
command: ArtifactCommands,
},
/// Configuration management /// Configuration management
Config { Config {
#[command(subcommand)] #[command(subcommand)]
@@ -129,6 +141,9 @@ enum Commands {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// Install HMAC-only JWT crypto provider (must be before any token operations)
attune_common::auth::install_crypto_provider();
let cli = Cli::parse(); let cli = Cli::parse();
// Initialize logging // Initialize logging
@@ -138,14 +153,17 @@ async fn main() {
.init(); .init();
} }
// Determine output format from flags // Determine output format: explicit CLI flags > config file > default (table)
let output_format = if cli.json { let cli_override = if cli.json {
output::OutputFormat::Json Some(output::OutputFormat::Json)
} else if cli.yaml { } else if cli.yaml {
output::OutputFormat::Yaml Some(output::OutputFormat::Yaml)
} else { } else {
cli.output cli.output
}; };
let config_for_format =
config::CliConfig::load_with_profile(cli.profile.as_deref()).unwrap_or_default();
let output_format = config_for_format.effective_format(cli_override);
let result = match cli.command { let result = match cli.command {
Commands::Auth { command } => { Commands::Auth { command } => {
@@ -169,6 +187,10 @@ async fn main() {
commands::rule::handle_rule_command(&cli.profile, command, &cli.api_url, output_format) commands::rule::handle_rule_command(&cli.profile, command, &cli.api_url, output_format)
.await .await
} }
Commands::Key { command } => {
commands::key::handle_key_command(&cli.profile, command, &cli.api_url, output_format)
.await
}
Commands::Execution { command } => { Commands::Execution { command } => {
commands::execution::handle_execution_command( commands::execution::handle_execution_command(
&cli.profile, &cli.profile,
@@ -205,6 +227,15 @@ async fn main() {
) )
.await .await
} }
Commands::Artifact { command } => {
commands::artifact::handle_artifact_command(
&cli.profile,
command,
&cli.api_url,
output_format,
)
.await
}
Commands::Config { command } => { Commands::Config { command } => {
commands::config::handle_config_command(&cli.profile, command, output_format).await commands::config::handle_config_command(&cli.profile, command, output_format).await
} }

View File

@@ -115,11 +115,33 @@ fn create_test_index(packs: &[(&str, &str)]) -> TempDir {
temp_dir temp_dir
} }
/// Create an isolated CLI command that never touches the user's real config.
///
/// Returns `(Command, TempDir)` — the `TempDir` must be kept alive for the
/// duration of the test so the config directory isn't deleted prematurely.
fn isolated_cmd() -> (Command, TempDir) {
let config_dir = TempDir::new().expect("Failed to create temp config dir");
// Write a minimal default config so the CLI doesn't try to create one
let attune_dir = config_dir.path().join("attune");
fs::create_dir_all(&attune_dir).expect("Failed to create attune config dir");
fs::write(
attune_dir.join("config.yaml"),
"profile: default\nformat: table\nprofiles:\n default:\n api_url: http://localhost:8080\n",
)
.expect("Failed to write test config");
let mut cmd = Command::cargo_bin("attune").unwrap();
cmd.env("XDG_CONFIG_HOME", config_dir.path())
.env("HOME", config_dir.path());
(cmd, config_dir)
}
#[test] #[test]
fn test_pack_checksum_directory() { fn test_pack_checksum_directory() {
let pack_dir = create_test_pack("checksum-test", "1.0.0", &[]); let pack_dir = create_test_pack("checksum-test", "1.0.0", &[]);
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("--output") cmd.arg("--output")
.arg("table") .arg("table")
.arg("pack") .arg("pack")
@@ -135,7 +157,7 @@ fn test_pack_checksum_directory() {
fn test_pack_checksum_json_output() { fn test_pack_checksum_json_output() {
let pack_dir = create_test_pack("checksum-json", "1.0.0", &[]); let pack_dir = create_test_pack("checksum-json", "1.0.0", &[]);
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("--output") cmd.arg("--output")
.arg("json") .arg("json")
.arg("pack") .arg("pack")
@@ -153,7 +175,7 @@ fn test_pack_checksum_json_output() {
#[test] #[test]
fn test_pack_checksum_nonexistent_path() { fn test_pack_checksum_nonexistent_path() {
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("pack").arg("checksum").arg("/nonexistent/path"); cmd.arg("pack").arg("checksum").arg("/nonexistent/path");
cmd.assert().failure().stderr( cmd.assert().failure().stderr(
@@ -165,7 +187,7 @@ fn test_pack_checksum_nonexistent_path() {
fn test_pack_index_entry_generates_valid_json() { fn test_pack_index_entry_generates_valid_json() {
let pack_dir = create_test_pack("index-entry-test", "1.2.3", &[]); let pack_dir = create_test_pack("index-entry-test", "1.2.3", &[]);
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("--output") cmd.arg("--output")
.arg("json") .arg("json")
.arg("pack") .arg("pack")
@@ -199,7 +221,7 @@ fn test_pack_index_entry_generates_valid_json() {
fn test_pack_index_entry_with_archive_url() { fn test_pack_index_entry_with_archive_url() {
let pack_dir = create_test_pack("archive-test", "2.0.0", &[]); let pack_dir = create_test_pack("archive-test", "2.0.0", &[]);
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("--output") cmd.arg("--output")
.arg("json") .arg("json")
.arg("pack") .arg("pack")
@@ -227,7 +249,7 @@ fn test_pack_index_entry_missing_pack_yaml() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("readme.txt"), "No pack.yaml here").unwrap(); fs::write(temp_dir.path().join("readme.txt"), "No pack.yaml here").unwrap();
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("pack") cmd.arg("pack")
.arg("index-entry") .arg("index-entry")
.arg(temp_dir.path().to_str().unwrap()); .arg(temp_dir.path().to_str().unwrap());
@@ -244,7 +266,7 @@ fn test_pack_index_update_adds_new_entry() {
let pack_dir = create_test_pack("new-pack", "1.0.0", &[]); let pack_dir = create_test_pack("new-pack", "1.0.0", &[]);
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("pack") cmd.arg("pack")
.arg("index-update") .arg("index-update")
.arg("--index") .arg("--index")
@@ -273,7 +295,7 @@ fn test_pack_index_update_prevents_duplicate_without_flag() {
let pack_dir = create_test_pack("existing-pack", "1.0.0", &[]); let pack_dir = create_test_pack("existing-pack", "1.0.0", &[]);
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("pack") cmd.arg("pack")
.arg("index-update") .arg("index-update")
.arg("--index") .arg("--index")
@@ -294,7 +316,7 @@ fn test_pack_index_update_with_update_flag() {
let pack_dir = create_test_pack("existing-pack", "2.0.0", &[]); let pack_dir = create_test_pack("existing-pack", "2.0.0", &[]);
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("pack") cmd.arg("pack")
.arg("index-update") .arg("index-update")
.arg("--index") .arg("--index")
@@ -327,7 +349,7 @@ fn test_pack_index_update_invalid_index_file() {
let pack_dir = create_test_pack("test-pack", "1.0.0", &[]); let pack_dir = create_test_pack("test-pack", "1.0.0", &[]);
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("pack") cmd.arg("pack")
.arg("index-update") .arg("index-update")
.arg("--index") .arg("--index")
@@ -345,8 +367,10 @@ fn test_pack_index_merge_combines_indexes() {
let output_dir = TempDir::new().unwrap(); let output_dir = TempDir::new().unwrap();
let output_path = output_dir.path().join("merged.json"); let output_path = output_dir.path().join("merged.json");
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("pack") cmd.arg("--output")
.arg("table")
.arg("pack")
.arg("index-merge") .arg("index-merge")
.arg("--file") .arg("--file")
.arg(output_path.to_str().unwrap()) .arg(output_path.to_str().unwrap())
@@ -372,8 +396,10 @@ fn test_pack_index_merge_deduplicates() {
let output_dir = TempDir::new().unwrap(); let output_dir = TempDir::new().unwrap();
let output_path = output_dir.path().join("merged.json"); let output_path = output_dir.path().join("merged.json");
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("pack") cmd.arg("--output")
.arg("table")
.arg("pack")
.arg("index-merge") .arg("index-merge")
.arg("--file") .arg("--file")
.arg(output_path.to_str().unwrap()) .arg(output_path.to_str().unwrap())
@@ -403,7 +429,7 @@ fn test_pack_index_merge_output_exists_without_force() {
let output_path = output_dir.path().join("merged.json"); let output_path = output_dir.path().join("merged.json");
fs::write(&output_path, "existing content").unwrap(); fs::write(&output_path, "existing content").unwrap();
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("pack") cmd.arg("pack")
.arg("index-merge") .arg("index-merge")
.arg("--file") .arg("--file")
@@ -423,7 +449,7 @@ fn test_pack_index_merge_with_force_flag() {
let output_path = output_dir.path().join("merged.json"); let output_path = output_dir.path().join("merged.json");
fs::write(&output_path, "existing content").unwrap(); fs::write(&output_path, "existing content").unwrap();
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("pack") cmd.arg("pack")
.arg("index-merge") .arg("index-merge")
.arg("--file") .arg("--file")
@@ -443,7 +469,7 @@ fn test_pack_index_merge_empty_input_list() {
let output_dir = TempDir::new().unwrap(); let output_dir = TempDir::new().unwrap();
let output_path = output_dir.path().join("merged.json"); let output_path = output_dir.path().join("merged.json");
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("pack") cmd.arg("pack")
.arg("index-merge") .arg("index-merge")
.arg("--file") .arg("--file")
@@ -459,8 +485,10 @@ fn test_pack_index_merge_missing_input_file() {
let output_dir = TempDir::new().unwrap(); let output_dir = TempDir::new().unwrap();
let output_path = output_dir.path().join("merged.json"); let output_path = output_dir.path().join("merged.json");
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("pack") cmd.arg("--output")
.arg("table")
.arg("pack")
.arg("index-merge") .arg("index-merge")
.arg("--file") .arg("--file")
.arg(output_path.to_str().unwrap()) .arg(output_path.to_str().unwrap())
@@ -483,7 +511,7 @@ fn test_pack_commands_help() {
]; ];
for args in commands { for args in commands {
let mut cmd = Command::cargo_bin("attune").unwrap(); let (mut cmd, _config_dir) = isolated_cmd();
for arg in &args { for arg in &args {
cmd.arg(arg); cmd.arg(arg);
} }

View File

@@ -20,7 +20,7 @@ async fn test_config_show_default() {
cmd.assert() cmd.assert()
.success() .success()
.stdout(predicate::str::contains("current_profile")) .stdout(predicate::str::contains("profile"))
.stdout(predicate::str::contains("api_url")); .stdout(predicate::str::contains("api_url"));
} }
@@ -38,7 +38,7 @@ async fn test_config_show_json_output() {
cmd.assert() cmd.assert()
.success() .success()
.stdout(predicate::str::contains(r#""current_profile""#)) .stdout(predicate::str::contains(r#""profile""#))
.stdout(predicate::str::contains(r#""api_url""#)); .stdout(predicate::str::contains(r#""api_url""#));
} }
@@ -56,7 +56,7 @@ async fn test_config_show_yaml_output() {
cmd.assert() cmd.assert()
.success() .success()
.stdout(predicate::str::contains("current_profile:")) .stdout(predicate::str::contains("profile:"))
.stdout(predicate::str::contains("api_url:")); .stdout(predicate::str::contains("api_url:"));
} }
@@ -118,7 +118,7 @@ async fn test_config_set_api_url() {
} }
#[tokio::test] #[tokio::test]
async fn test_config_set_output_format() { async fn test_config_set_format() {
let fixture = TestFixture::new().await; let fixture = TestFixture::new().await;
fixture.write_default_config(); fixture.write_default_config();
@@ -127,7 +127,7 @@ async fn test_config_set_output_format() {
.env("HOME", fixture.config_dir_path()) .env("HOME", fixture.config_dir_path())
.arg("config") .arg("config")
.arg("set") .arg("set")
.arg("output_format") .arg("format")
.arg("json"); .arg("json");
cmd.assert() cmd.assert()
@@ -137,7 +137,7 @@ async fn test_config_set_output_format() {
// Verify the change was persisted // Verify the change was persisted
let config_content = let config_content =
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config"); std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
assert!(config_content.contains("output_format: json")); assert!(config_content.contains("format: json"));
} }
#[tokio::test] #[tokio::test]
@@ -273,7 +273,7 @@ async fn test_profile_use_switch() {
// Verify the current profile was changed // Verify the current profile was changed
let config_content = let config_content =
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config"); std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
assert!(config_content.contains("current_profile: staging")); assert!(config_content.contains("profile: staging"));
} }
#[tokio::test] #[tokio::test]
@@ -384,7 +384,7 @@ async fn test_profile_override_with_flag() {
// Verify current profile wasn't changed in the config file // Verify current profile wasn't changed in the config file
let config_content = let config_content =
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config"); std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
assert!(config_content.contains("current_profile: default")); assert!(config_content.contains("profile: default"));
} }
#[tokio::test] #[tokio::test]
@@ -405,28 +405,35 @@ async fn test_profile_override_with_env_var() {
// Verify current profile wasn't changed in the config file // Verify current profile wasn't changed in the config file
let config_content = let config_content =
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config"); std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
assert!(config_content.contains("current_profile: default")); assert!(config_content.contains("profile: default"));
} }
#[tokio::test] #[tokio::test]
async fn test_profile_with_custom_output_format() { async fn test_config_format_respected_by_commands() {
let fixture = TestFixture::new().await; let fixture = TestFixture::new().await;
fixture.write_multi_profile_config(); // Write a config with format set to json
let config = format!(
r#"
profile: default
format: json
profiles:
default:
api_url: {}
description: Test server
"#,
fixture.server_url()
);
fixture.write_config(&config);
// Switch to production which has json output format // Run config list without --json flag; should output JSON because config says so
let mut cmd = Command::cargo_bin("attune").unwrap(); let mut cmd = Command::cargo_bin("attune").unwrap();
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path()) cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
.env("HOME", fixture.config_dir_path()) .env("HOME", fixture.config_dir_path())
.arg("config") .arg("config")
.arg("use") .arg("list");
.arg("production");
cmd.assert().success(); // JSON output contains curly braces
cmd.assert().success().stdout(predicate::str::contains("{"));
// Verify the profile has custom output format
let config_content =
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
assert!(config_content.contains("output_format: json"));
} }
#[tokio::test] #[tokio::test]
@@ -443,7 +450,7 @@ async fn test_config_list_all_keys() {
cmd.assert() cmd.assert()
.success() .success()
.stdout(predicate::str::contains("api_url")) .stdout(predicate::str::contains("api_url"))
.stdout(predicate::str::contains("output_format")) .stdout(predicate::str::contains("format"))
.stdout(predicate::str::contains("auth_token")); .stdout(predicate::str::contains("auth_token"));
} }

View File

@@ -55,6 +55,8 @@ utoipa = { workspace = true }
# JWT # JWT
jsonwebtoken = { workspace = true } jsonwebtoken = { workspace = true }
hmac = { workspace = true }
signature = { workspace = true }
# Encryption # Encryption
argon2 = { workspace = true } argon2 = { workspace = true }

View File

@@ -0,0 +1,107 @@
//! Shared bootstrap helpers for injected agent binaries.
use crate::agent_runtime_detection::{
detect_runtimes, format_as_env_value, print_detection_report_for_env, DetectedRuntime,
};
use tracing::{info, warn};
#[derive(Debug, Clone)]
pub struct RuntimeBootstrapResult {
pub runtimes_override: Option<String>,
pub detected_runtimes: Option<Vec<DetectedRuntime>>,
}
/// Detect runtimes and populate the agent runtime environment variable when needed.
///
/// This must run before the Tokio runtime starts because it may mutate process
/// environment variables.
pub fn bootstrap_runtime_env(env_var_name: &str) -> RuntimeBootstrapResult {
let runtimes_override = std::env::var(env_var_name).ok();
let mut detected_runtimes = None;
if let Some(ref override_value) = runtimes_override {
info!(
"{} already set (override): {}",
env_var_name, override_value
);
info!("Running auto-detection for override-specified runtimes...");
let detected = detect_runtimes();
let override_names: Vec<&str> = override_value.split(',').map(|s| s.trim()).collect();
let filtered: Vec<_> = detected
.into_iter()
.filter(|rt| {
let lower_name = rt.name.to_ascii_lowercase();
override_names
.iter()
.any(|ov| ov.to_ascii_lowercase() == lower_name)
})
.collect();
if filtered.is_empty() {
warn!(
"None of the override runtimes ({}) were found on this system",
override_value
);
} else {
info!(
"Matched {} override runtime(s) to detected interpreters:",
filtered.len()
);
for rt in &filtered {
match &rt.version {
Some(ver) => info!(" ✓ {} — {} ({})", rt.name, rt.path, ver),
None => info!(" ✓ {} — {}", rt.name, rt.path),
}
}
detected_runtimes = Some(filtered);
}
} else {
info!("No {} override — running auto-detection...", env_var_name);
let detected = detect_runtimes();
if detected.is_empty() {
warn!("No runtimes detected! The agent may not be able to execute any work.");
} else {
info!("Detected {} runtime(s):", detected.len());
for rt in &detected {
match &rt.version {
Some(ver) => info!(" ✓ {} — {} ({})", rt.name, rt.path, ver),
None => info!(" ✓ {} — {}", rt.name, rt.path),
}
}
let runtime_csv = format_as_env_value(&detected);
info!("Setting {}={}", env_var_name, runtime_csv);
std::env::set_var(env_var_name, &runtime_csv);
detected_runtimes = Some(detected);
}
}
RuntimeBootstrapResult {
runtimes_override,
detected_runtimes,
}
}
pub fn print_detect_only_report(env_var_name: &str, result: &RuntimeBootstrapResult) {
if result.runtimes_override.is_some() {
info!("--detect-only: re-running detection to show what is available on this system...");
println!(
"NOTE: {} is set — auto-detection was skipped during normal startup.",
env_var_name
);
println!(" Showing what auto-detection would find on this system:");
println!();
let detected = detect_runtimes();
print_detection_report_for_env(env_var_name, &detected);
} else if let Some(ref detected) = result.detected_runtimes {
print_detection_report_for_env(env_var_name, detected);
} else {
let detected = detect_runtimes();
print_detection_report_for_env(env_var_name, &detected);
}
}

View File

@@ -0,0 +1,306 @@
//! Runtime auto-detection for injected Attune agent binaries.
//!
//! This module probes the local system directly for well-known interpreters,
//! without requiring database access.
use serde::{Deserialize, Serialize};
use std::fmt;
use std::process::Command;
use tracing::{debug, info};
/// A runtime interpreter discovered on the local system.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectedRuntime {
/// Canonical runtime name (for example, "python" or "node").
pub name: String,
/// Absolute path to the interpreter binary.
pub path: String,
/// Version string if the version command succeeded.
pub version: Option<String>,
}
impl fmt::Display for DetectedRuntime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.version {
Some(v) => write!(f, "{} ({}, v{})", self.name, self.path, v),
None => write!(f, "{} ({})", self.name, self.path),
}
}
}
struct RuntimeCandidate {
name: &'static str,
binaries: &'static [&'static str],
version_args: &'static [&'static str],
version_parser: VersionParser,
}
enum VersionParser {
SemverLike,
JavaStyle,
}
fn candidates() -> Vec<RuntimeCandidate> {
vec![
RuntimeCandidate {
name: "shell",
binaries: &["bash", "sh"],
version_args: &["--version"],
version_parser: VersionParser::SemverLike,
},
RuntimeCandidate {
name: "python",
binaries: &["python3", "python"],
version_args: &["--version"],
version_parser: VersionParser::SemverLike,
},
RuntimeCandidate {
name: "node",
binaries: &["node", "nodejs"],
version_args: &["--version"],
version_parser: VersionParser::SemverLike,
},
RuntimeCandidate {
name: "ruby",
binaries: &["ruby"],
version_args: &["--version"],
version_parser: VersionParser::SemverLike,
},
RuntimeCandidate {
name: "go",
binaries: &["go"],
version_args: &["version"],
version_parser: VersionParser::SemverLike,
},
RuntimeCandidate {
name: "java",
binaries: &["java"],
version_args: &["-version"],
version_parser: VersionParser::JavaStyle,
},
RuntimeCandidate {
name: "r",
binaries: &["Rscript"],
version_args: &["--version"],
version_parser: VersionParser::SemverLike,
},
RuntimeCandidate {
name: "perl",
binaries: &["perl"],
version_args: &["--version"],
version_parser: VersionParser::SemverLike,
},
]
}
/// Detect available runtimes by probing the local system.
pub fn detect_runtimes() -> Vec<DetectedRuntime> {
info!("Starting runtime auto-detection...");
let mut detected = Vec::new();
for candidate in candidates() {
match detect_single_runtime(&candidate) {
Some(runtime) => {
info!(" ✓ Detected: {}", runtime);
detected.push(runtime);
}
None => {
debug!(" ✗ Not found: {}", candidate.name);
}
}
}
info!(
"Runtime auto-detection complete: found {} runtime(s): [{}]",
detected.len(),
detected
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
detected
}
fn detect_single_runtime(candidate: &RuntimeCandidate) -> Option<DetectedRuntime> {
for binary in candidate.binaries {
if let Some(path) = which_binary(binary) {
let version = get_version(&path, candidate.version_args, &candidate.version_parser);
return Some(DetectedRuntime {
name: candidate.name.to_string(),
path,
version,
});
}
}
None
}
fn which_binary(binary: &str) -> Option<String> {
if binary == "bash" || binary == "sh" {
let absolute_path = format!("/bin/{}", binary);
if std::path::Path::new(&absolute_path).exists() {
return Some(absolute_path);
}
}
match Command::new("which").arg(binary).output() {
Ok(output) if output.status.success() => {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if path.is_empty() {
None
} else {
Some(path)
}
}
Ok(_) => None,
Err(e) => {
debug!("'which' command failed ({}), trying 'command -v'", e);
match Command::new("sh")
.args(["-c", &format!("command -v {}", binary)])
.output()
{
Ok(output) if output.status.success() => {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if path.is_empty() {
None
} else {
Some(path)
}
}
_ => None,
}
}
}
}
fn get_version(binary_path: &str, version_args: &[&str], parser: &VersionParser) -> Option<String> {
let output = match Command::new(binary_path).args(version_args).output() {
Ok(output) => output,
Err(e) => {
debug!("Failed to run version command for {}: {}", binary_path, e);
return None;
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{}{}", stdout, stderr);
match parser {
VersionParser::SemverLike => parse_semver_like(&combined),
VersionParser::JavaStyle => parse_java_version(&combined),
}
}
fn parse_semver_like(output: &str) -> Option<String> {
let re = regex::Regex::new(r"(?:v|go)?(\d+\.\d+(?:\.\d+)?)").ok()?;
re.captures(output)
.and_then(|captures| captures.get(1).map(|m| m.as_str().to_string()))
}
fn parse_java_version(output: &str) -> Option<String> {
let quoted_re = regex::Regex::new(r#"version\s+"([^"]+)""#).ok()?;
if let Some(captures) = quoted_re.captures(output) {
return captures.get(1).map(|m| m.as_str().to_string());
}
parse_semver_like(output)
}
pub fn format_as_env_value(runtimes: &[DetectedRuntime]) -> String {
runtimes
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>()
.join(",")
}
pub fn print_detection_report_for_env(env_var_name: &str, runtimes: &[DetectedRuntime]) {
println!("=== Attune Agent Runtime Detection Report ===");
println!();
if runtimes.is_empty() {
println!("No runtimes detected!");
println!();
println!("The agent could not find any supported interpreter binaries.");
println!("Ensure at least one of the following is installed and on PATH:");
println!(" - bash / sh (shell scripts)");
println!(" - python3 / python (Python scripts)");
println!(" - node / nodejs (Node.js scripts)");
println!(" - ruby (Ruby scripts)");
println!(" - go (Go programs)");
println!(" - java (Java programs)");
println!(" - Rscript (R scripts)");
println!(" - perl (Perl scripts)");
} else {
println!("Detected {} runtime(s):", runtimes.len());
println!();
for rt in runtimes {
let version_str = rt.version.as_deref().unwrap_or("unknown version");
println!("{:<10} {} ({})", rt.name, rt.path, version_str);
}
}
println!();
println!("{}={}", env_var_name, format_as_env_value(runtimes));
println!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_semver_like_python() {
assert_eq!(
parse_semver_like("Python 3.12.1"),
Some("3.12.1".to_string())
);
}
#[test]
fn test_parse_semver_like_node() {
assert_eq!(parse_semver_like("v20.11.0"), Some("20.11.0".to_string()));
}
#[test]
fn test_parse_semver_like_go() {
assert_eq!(
parse_semver_like("go version go1.22.0 linux/amd64"),
Some("1.22.0".to_string())
);
}
#[test]
fn test_parse_java_version_openjdk() {
assert_eq!(
parse_java_version(r#"openjdk version "21.0.1" 2023-10-17"#),
Some("21.0.1".to_string())
);
}
#[test]
fn test_format_as_env_value_multiple() {
let runtimes = vec![
DetectedRuntime {
name: "shell".to_string(),
path: "/bin/bash".to_string(),
version: Some("5.2.15".to_string()),
},
DetectedRuntime {
name: "python".to_string(),
path: "/usr/bin/python3".to_string(),
version: Some("3.12.1".to_string()),
},
];
assert_eq!(format_as_env_value(&runtimes), "shell,python");
}
}

View File

@@ -0,0 +1,193 @@
//! HMAC-only CryptoProvider for jsonwebtoken v10.
//!
//! The `jsonwebtoken` crate v10 requires a `CryptoProvider` to be installed
//! before any signing/verification operations. The built-in `rust_crypto`
//! feature pulls in the `rsa` crate, which has an unpatched advisory
//! (RUSTSEC-2023-0071 — Marvin Attack timing sidechannel).
//!
//! Since Attune only uses HMAC-SHA2 (HS256/HS384/HS512) for JWT signing,
//! this module provides a minimal CryptoProvider that supports only those
//! algorithms, avoiding the `rsa` dependency entirely.
//!
//! Call [`install()`] once at process startup (before any JWT operations).
use hmac::{Hmac, Mac};
use jsonwebtoken::crypto::{CryptoProvider, JwkUtils, JwtSigner, JwtVerifier};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey};
use sha2::{Sha256, Sha384, Sha512};
use signature::{Signer, Verifier};
use std::sync::Once;
type HmacSha256 = Hmac<Sha256>;
type HmacSha384 = Hmac<Sha384>;
type HmacSha512 = Hmac<Sha512>;
// ---------------------------------------------------------------------------
// Signers
// ---------------------------------------------------------------------------
macro_rules! define_hmac_signer {
($name:ident, $alg:expr, $hmac_type:ty) => {
struct $name($hmac_type);
impl $name {
fn new(key: &EncodingKey) -> jsonwebtoken::errors::Result<Self> {
let inner = <$hmac_type>::new_from_slice(key.try_get_hmac_secret()?)
.map_err(|_| jsonwebtoken::errors::ErrorKind::InvalidKeyFormat)?;
Ok(Self(inner))
}
}
impl Signer<Vec<u8>> for $name {
fn try_sign(&self, msg: &[u8]) -> std::result::Result<Vec<u8>, signature::Error> {
let mut mac = self.0.clone();
mac.reset();
mac.update(msg);
Ok(mac.finalize().into_bytes().to_vec())
}
}
impl JwtSigner for $name {
fn algorithm(&self) -> Algorithm {
$alg
}
}
};
}
define_hmac_signer!(Hs256Signer, Algorithm::HS256, HmacSha256);
define_hmac_signer!(Hs384Signer, Algorithm::HS384, HmacSha384);
define_hmac_signer!(Hs512Signer, Algorithm::HS512, HmacSha512);
// ---------------------------------------------------------------------------
// Verifiers
// ---------------------------------------------------------------------------
macro_rules! define_hmac_verifier {
($name:ident, $alg:expr, $hmac_type:ty) => {
struct $name($hmac_type);
impl $name {
fn new(key: &DecodingKey) -> jsonwebtoken::errors::Result<Self> {
let inner = <$hmac_type>::new_from_slice(key.try_get_hmac_secret()?)
.map_err(|_| jsonwebtoken::errors::ErrorKind::InvalidKeyFormat)?;
Ok(Self(inner))
}
}
impl Verifier<Vec<u8>> for $name {
fn verify(
&self,
msg: &[u8],
sig: &Vec<u8>,
) -> std::result::Result<(), signature::Error> {
let mut mac = self.0.clone();
mac.reset();
mac.update(msg);
mac.verify_slice(sig).map_err(signature::Error::from_source)
}
}
impl JwtVerifier for $name {
fn algorithm(&self) -> Algorithm {
$alg
}
}
};
}
define_hmac_verifier!(Hs256Verifier, Algorithm::HS256, HmacSha256);
define_hmac_verifier!(Hs384Verifier, Algorithm::HS384, HmacSha384);
define_hmac_verifier!(Hs512Verifier, Algorithm::HS512, HmacSha512);
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
fn hmac_signer_factory(
algorithm: &Algorithm,
key: &EncodingKey,
) -> jsonwebtoken::errors::Result<Box<dyn JwtSigner>> {
match algorithm {
Algorithm::HS256 => Ok(Box::new(Hs256Signer::new(key)?)),
Algorithm::HS384 => Ok(Box::new(Hs384Signer::new(key)?)),
Algorithm::HS512 => Ok(Box::new(Hs512Signer::new(key)?)),
_other => Err(jsonwebtoken::errors::ErrorKind::InvalidAlgorithm.into()),
}
}
fn hmac_verifier_factory(
algorithm: &Algorithm,
key: &DecodingKey,
) -> jsonwebtoken::errors::Result<Box<dyn JwtVerifier>> {
match algorithm {
Algorithm::HS256 => Ok(Box::new(Hs256Verifier::new(key)?)),
Algorithm::HS384 => Ok(Box::new(Hs384Verifier::new(key)?)),
Algorithm::HS512 => Ok(Box::new(Hs512Verifier::new(key)?)),
_other => Err(jsonwebtoken::errors::ErrorKind::InvalidAlgorithm.into()),
}
}
/// HMAC-only [`CryptoProvider`]. Supports HS256, HS384, HS512 only.
/// JWK utility functions (RSA/EC key extraction) are stubbed out since
/// Attune never uses asymmetric JWKs.
static HMAC_PROVIDER: CryptoProvider = CryptoProvider {
signer_factory: hmac_signer_factory,
verifier_factory: hmac_verifier_factory,
jwk_utils: JwkUtils::new_unimplemented(),
};
static INIT: Once = Once::new();
/// Install the HMAC-only crypto provider for jsonwebtoken.
///
/// Safe to call multiple times — only the first call takes effect.
/// Must be called before any JWT encode/decode operations.
pub fn install() {
INIT.call_once(|| {
// install_default returns Err if already installed (e.g., by a feature-based
// provider). That's fine — we only care that *some* provider is present.
let _ = HMAC_PROVIDER.install_default();
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_install_idempotent() {
install();
install(); // second call should not panic
}
#[test]
fn test_hmac_sign_and_verify() {
install();
let secret = b"test-secret-key";
let encoding_key = EncodingKey::from_secret(secret);
let decoding_key = DecodingKey::from_secret(secret);
let message = b"hello world";
let signer =
hmac_signer_factory(&Algorithm::HS256, &encoding_key).expect("should create signer");
let sig = signer.try_sign(message).expect("should sign");
let verifier = hmac_verifier_factory(&Algorithm::HS256, &decoding_key)
.expect("should create verifier");
verifier
.verify(message, &sig)
.expect("signature should verify");
}
#[test]
fn test_unsupported_algorithm_rejected() {
install();
let key = EncodingKey::from_secret(b"key");
let result = hmac_signer_factory(&Algorithm::RS256, &key);
assert!(result.is_err());
}
}

View File

@@ -248,8 +248,10 @@ pub fn extract_token_from_header(auth_header: &str) -> Option<&str> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::auth::crypto_provider;
fn test_config() -> JwtConfig { fn test_config() -> JwtConfig {
crypto_provider::install();
JwtConfig { JwtConfig {
secret: "test_secret_key_for_testing".to_string(), secret: "test_secret_key_for_testing".to_string(),
access_token_expiration: 3600, access_token_expiration: 3600,
@@ -260,6 +262,7 @@ mod tests {
#[test] #[test]
fn test_generate_and_validate_access_token() { fn test_generate_and_validate_access_token() {
let config = test_config(); let config = test_config();
let token = let token =
generate_access_token(123, "testuser", &config).expect("Failed to generate token"); generate_access_token(123, "testuser", &config).expect("Failed to generate token");
@@ -293,6 +296,7 @@ mod tests {
#[test] #[test]
fn test_token_with_wrong_secret() { fn test_token_with_wrong_secret() {
let config = test_config(); let config = test_config();
let token = generate_access_token(789, "user", &config).expect("Failed to generate token"); let token = generate_access_token(789, "user", &config).expect("Failed to generate token");
let wrong_config = JwtConfig { let wrong_config = JwtConfig {
@@ -306,6 +310,7 @@ mod tests {
#[test] #[test]
fn test_expired_token() { fn test_expired_token() {
crypto_provider::install();
let now = Utc::now().timestamp(); let now = Utc::now().timestamp();
let expired_claims = Claims { let expired_claims = Claims {
sub: "999".to_string(), sub: "999".to_string(),

View File

@@ -4,8 +4,10 @@
//! that are used by the API (for all token types), the worker (for execution-scoped //! that are used by the API (for all token types), the worker (for execution-scoped
//! tokens), and the sensor service (for sensor tokens). //! tokens), and the sensor service (for sensor tokens).
pub mod crypto_provider;
pub mod jwt; pub mod jwt;
pub use crypto_provider::install as install_crypto_provider;
pub use jwt::{ pub use jwt::{
extract_token_from_header, generate_access_token, generate_execution_token, extract_token_from_header, generate_access_token, generate_execution_token,
generate_refresh_token, generate_sensor_token, generate_token, validate_token, Claims, generate_refresh_token, generate_sensor_token, generate_token, validate_token, Claims,

View File

@@ -295,6 +295,22 @@ pub struct SecurityConfig {
/// Enable authentication /// Enable authentication
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub enable_auth: bool, pub enable_auth: bool,
/// Allow unauthenticated self-service user registration
#[serde(default)]
pub allow_self_registration: bool,
/// Login page visibility defaults for the web UI.
#[serde(default)]
pub login_page: LoginPageConfig,
/// Optional OpenID Connect configuration for browser login.
#[serde(default)]
pub oidc: Option<OidcConfig>,
/// Optional LDAP configuration for username/password login against a directory.
#[serde(default)]
pub ldap: Option<LdapConfig>,
} }
fn default_jwt_access_expiration() -> u64 { fn default_jwt_access_expiration() -> u64 {
@@ -305,6 +321,162 @@ fn default_jwt_refresh_expiration() -> u64 {
604800 // 7 days 604800 // 7 days
} }
/// Web login page configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginPageConfig {
/// Show the local username/password form by default.
#[serde(default = "default_true")]
pub show_local_login: bool,
/// Show the OIDC/SSO option by default when configured.
#[serde(default = "default_true")]
pub show_oidc_login: bool,
/// Show the LDAP option by default when configured.
#[serde(default = "default_true")]
pub show_ldap_login: bool,
}
impl Default for LoginPageConfig {
fn default() -> Self {
Self {
show_local_login: true,
show_oidc_login: true,
show_ldap_login: true,
}
}
}
/// OpenID Connect configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcConfig {
/// Enable OpenID Connect login flow.
#[serde(default)]
pub enabled: bool,
/// OpenID Provider discovery document URL.
pub discovery_url: String,
/// Confidential client ID.
pub client_id: String,
/// Provider name used in login-page overrides such as `?auth=<provider_name>`.
#[serde(default = "default_oidc_provider_name")]
pub provider_name: String,
/// User-facing provider label shown on the login page.
pub provider_label: Option<String>,
/// Optional icon URL shown beside the provider label on the login page.
pub provider_icon_url: Option<String>,
/// Confidential client secret.
pub client_secret: Option<String>,
/// Redirect URI registered with the provider.
pub redirect_uri: String,
/// Optional post-logout redirect URI.
pub post_logout_redirect_uri: Option<String>,
/// Optional requested scopes in addition to `openid email profile`.
#[serde(default)]
pub scopes: Vec<String>,
}
fn default_oidc_provider_name() -> String {
"oidc".to_string()
}
/// LDAP authentication configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LdapConfig {
/// Enable LDAP login flow.
#[serde(default)]
pub enabled: bool,
/// LDAP server URL (e.g., "ldap://ldap.example.com:389" or "ldaps://ldap.example.com:636").
pub url: String,
/// Bind DN template. Use `{login}` as placeholder for the user-supplied login.
/// Example: "uid={login},ou=users,dc=example,dc=com"
/// If not set, an anonymous bind is attempted first to search for the user.
pub bind_dn_template: Option<String>,
/// Base DN for user searches when bind_dn_template is not set.
/// Example: "ou=users,dc=example,dc=com"
pub user_search_base: Option<String>,
/// LDAP search filter template. Use `{login}` as placeholder.
/// Default: "(uid={login})"
#[serde(default = "default_ldap_user_filter")]
pub user_filter: String,
/// DN of a service account used to search for users (required when using search-based auth).
pub search_bind_dn: Option<String>,
/// Password for the search service account.
pub search_bind_password: Option<String>,
/// LDAP attribute to use as the login name. Default: "uid"
#[serde(default = "default_ldap_login_attr")]
pub login_attr: String,
/// LDAP attribute to use as the email. Default: "mail"
#[serde(default = "default_ldap_email_attr")]
pub email_attr: String,
/// LDAP attribute to use as the display name. Default: "cn"
#[serde(default = "default_ldap_display_name_attr")]
pub display_name_attr: String,
/// LDAP attribute that contains group membership. Default: "memberOf"
#[serde(default = "default_ldap_group_attr")]
pub group_attr: String,
/// Whether to use STARTTLS. Default: false
#[serde(default)]
pub starttls: bool,
/// Whether to skip TLS certificate verification (insecure!). Default: false
#[serde(default)]
pub danger_skip_tls_verify: bool,
/// Provider name used in login-page overrides such as `?auth=<provider_name>`.
#[serde(default = "default_ldap_provider_name")]
pub provider_name: String,
/// User-facing provider label shown on the login page.
pub provider_label: Option<String>,
/// Optional icon URL shown beside the provider label on the login page.
pub provider_icon_url: Option<String>,
}
fn default_ldap_provider_name() -> String {
"ldap".to_string()
}
fn default_ldap_user_filter() -> String {
"(uid={login})".to_string()
}
fn default_ldap_login_attr() -> String {
"uid".to_string()
}
fn default_ldap_email_attr() -> String {
"mail".to_string()
}
fn default_ldap_display_name_attr() -> String {
"cn".to_string()
}
fn default_ldap_group_attr() -> String {
"memberOf".to_string()
}
/// Worker configuration /// Worker configuration
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerConfig { pub struct WorkerConfig {
@@ -505,6 +677,15 @@ impl Default for PackRegistryConfig {
} }
} }
/// Agent binary distribution configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
/// Directory containing agent binary files
pub binary_dir: String,
/// Optional bootstrap token for authenticating agent binary downloads
pub bootstrap_token: Option<String>,
}
/// Executor service configuration /// Executor service configuration
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutorConfig { pub struct ExecutorConfig {
@@ -598,6 +779,9 @@ pub struct Config {
/// Executor configuration (optional, for executor service) /// Executor configuration (optional, for executor service)
pub executor: Option<ExecutorConfig>, pub executor: Option<ExecutorConfig>,
/// Agent configuration (optional, for agent binary distribution)
pub agent: Option<AgentConfig>,
} }
fn default_service_name() -> String { fn default_service_name() -> String {
@@ -676,6 +860,10 @@ impl Default for SecurityConfig {
jwt_refresh_expiration: default_jwt_refresh_expiration(), jwt_refresh_expiration: default_jwt_refresh_expiration(),
encryption_key: None, encryption_key: None,
enable_auth: true, enable_auth: true,
allow_self_registration: false,
login_page: LoginPageConfig::default(),
oidc: None,
ldap: None,
} }
} }
} }
@@ -795,6 +983,37 @@ impl Config {
)); ));
} }
if let Some(oidc) = &self.security.oidc {
if oidc.enabled {
if oidc.discovery_url.trim().is_empty() {
return Err(crate::Error::validation(
"OIDC discovery URL cannot be empty when OIDC is enabled",
));
}
if oidc.client_id.trim().is_empty() {
return Err(crate::Error::validation(
"OIDC client ID cannot be empty when OIDC is enabled",
));
}
if oidc
.client_secret
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
return Err(crate::Error::validation(
"OIDC client secret is required when OIDC is enabled",
));
}
if oidc.redirect_uri.trim().is_empty() {
return Err(crate::Error::validation(
"OIDC redirect URI cannot be empty when OIDC is enabled",
));
}
}
}
// Validate encryption key if provided // Validate encryption key if provided
if let Some(ref key) = self.security.encryption_key { if let Some(ref key) = self.security.encryption_key {
if key.len() < 32 { if key.len() < 32 {
@@ -859,6 +1078,7 @@ mod tests {
notifier: None, notifier: None,
pack_registry: PackRegistryConfig::default(), pack_registry: PackRegistryConfig::default(),
executor: None, executor: None,
agent: None,
}; };
assert_eq!(config.service_name, "attune"); assert_eq!(config.service_name, "attune");
@@ -924,6 +1144,10 @@ mod tests {
jwt_refresh_expiration: 604800, jwt_refresh_expiration: 604800,
encryption_key: Some("a".repeat(32)), encryption_key: Some("a".repeat(32)),
enable_auth: true, enable_auth: true,
allow_self_registration: false,
login_page: LoginPageConfig::default(),
oidc: None,
ldap: None,
}, },
worker: None, worker: None,
sensor: None, sensor: None,
@@ -933,6 +1157,7 @@ mod tests {
notifier: None, notifier: None,
pack_registry: PackRegistryConfig::default(), pack_registry: PackRegistryConfig::default(),
executor: None, executor: None,
agent: None,
}; };
assert!(config.validate().is_ok()); assert!(config.validate().is_ok());
@@ -946,4 +1171,102 @@ mod tests {
config.security.jwt_secret = None; config.security.jwt_secret = None;
assert!(config.validate().is_err()); assert!(config.validate().is_err());
} }
#[test]
fn test_ldap_config_defaults() {
let yaml = r#"
enabled: true
url: "ldap://localhost:389"
client_id: "test"
"#;
let cfg: LdapConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert!(cfg.enabled);
assert_eq!(cfg.url, "ldap://localhost:389");
assert_eq!(cfg.user_filter, "(uid={login})");
assert_eq!(cfg.login_attr, "uid");
assert_eq!(cfg.email_attr, "mail");
assert_eq!(cfg.display_name_attr, "cn");
assert_eq!(cfg.group_attr, "memberOf");
assert_eq!(cfg.provider_name, "ldap");
assert!(!cfg.starttls);
assert!(!cfg.danger_skip_tls_verify);
assert!(cfg.bind_dn_template.is_none());
assert!(cfg.user_search_base.is_none());
assert!(cfg.search_bind_dn.is_none());
assert!(cfg.search_bind_password.is_none());
assert!(cfg.provider_label.is_none());
assert!(cfg.provider_icon_url.is_none());
}
#[test]
fn test_ldap_config_full_deserialization() {
let yaml = r#"
enabled: true
url: "ldaps://ldap.corp.com:636"
bind_dn_template: "uid={login},ou=people,dc=corp,dc=com"
user_search_base: "ou=people,dc=corp,dc=com"
user_filter: "(sAMAccountName={login})"
search_bind_dn: "cn=svc,dc=corp,dc=com"
search_bind_password: "secret"
login_attr: "sAMAccountName"
email_attr: "userPrincipalName"
display_name_attr: "displayName"
group_attr: "memberOf"
starttls: true
danger_skip_tls_verify: true
provider_name: "corpldap"
provider_label: "Corporate Directory"
provider_icon_url: "https://corp.com/icon.svg"
"#;
let cfg: LdapConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert!(cfg.enabled);
assert_eq!(cfg.url, "ldaps://ldap.corp.com:636");
assert_eq!(
cfg.bind_dn_template.as_deref(),
Some("uid={login},ou=people,dc=corp,dc=com")
);
assert_eq!(
cfg.user_search_base.as_deref(),
Some("ou=people,dc=corp,dc=com")
);
assert_eq!(cfg.user_filter, "(sAMAccountName={login})");
assert_eq!(cfg.search_bind_dn.as_deref(), Some("cn=svc,dc=corp,dc=com"));
assert_eq!(cfg.search_bind_password.as_deref(), Some("secret"));
assert_eq!(cfg.login_attr, "sAMAccountName");
assert_eq!(cfg.email_attr, "userPrincipalName");
assert_eq!(cfg.display_name_attr, "displayName");
assert_eq!(cfg.group_attr, "memberOf");
assert!(cfg.starttls);
assert!(cfg.danger_skip_tls_verify);
assert_eq!(cfg.provider_name, "corpldap");
assert_eq!(cfg.provider_label.as_deref(), Some("Corporate Directory"));
assert_eq!(
cfg.provider_icon_url.as_deref(),
Some("https://corp.com/icon.svg")
);
}
#[test]
fn test_security_config_ldap_none_by_default() {
let yaml = r#"jwt_secret: "s""#;
let cfg: SecurityConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert!(cfg.ldap.is_none());
}
#[test]
fn test_login_page_show_ldap_default_true() {
let cfg: LoginPageConfig = serde_yaml_ng::from_str("{}").unwrap();
assert!(cfg.show_ldap_login);
}
#[test]
fn test_login_page_show_ldap_explicit_false() {
let cfg: LoginPageConfig = serde_yaml_ng::from_str("show_ldap_login: false").unwrap();
assert!(!cfg.show_ldap_login);
}
} }

Some files were not shown because too many files have changed in this diff Show More