57 Commits

Author SHA1 Message Date
David Culbreth
7ef2b59b23 working on arm64 native
Some checks failed
CI / Rustfmt (push) Successful in 24s
CI / Cargo Audit & Deny (push) Successful in 36s
CI / Security Blocking Checks (push) Successful in 9s
CI / Web Blocking Checks (push) Successful in 48s
CI / Web Advisory Checks (push) Successful in 37s
Publish Images / Resolve Publish Metadata (push) Successful in 2s
CI / Clippy (push) Failing after 1m53s
Publish Images / Publish Docker Dist Bundle (push) Failing after 8s
Publish Images / Publish web (amd64) (push) Successful in 56s
CI / Security Advisory Checks (push) Successful in 38s
Publish Images / Publish web (arm64) (push) Successful in 3m29s
CI / Tests (push) Successful in 9m21s
Publish Images / Build Rust Bundles (amd64) (push) Failing after 12m28s
Publish Images / Build Rust Bundles (arm64) (push) Successful in 12m20s
Publish Images / Publish agent (amd64) (push) Has been skipped
Publish Images / Publish api (amd64) (push) Has been skipped
Publish Images / Publish agent (arm64) (push) Has been skipped
Publish Images / Publish api (arm64) (push) Has been skipped
Publish Images / Publish executor (amd64) (push) Has been skipped
Publish Images / Publish notifier (amd64) (push) Has been skipped
Publish Images / Publish executor (arm64) (push) Has been skipped
Publish Images / Publish notifier (arm64) (push) Has been skipped
Publish Images / Publish manifest attune/agent (push) Has been skipped
Publish Images / Publish manifest attune/api (push) Has been skipped
Publish Images / Publish manifest attune/notifier (push) Has been skipped
Publish Images / Publish manifest attune/executor (push) Has been skipped
Publish Images / Publish manifest attune/web (push) Has been skipped
2026-03-27 16:37:46 -05:00
3a13bf754a fixing docker compose distribution
Some checks failed
CI / Rustfmt (push) Successful in 20s
CI / Clippy (push) Successful in 2m3s
CI / Cargo Audit & Deny (push) Successful in 32s
CI / Web Blocking Checks (push) Successful in 1m21s
CI / Security Blocking Checks (push) Successful in 10s
CI / Web Advisory Checks (push) Successful in 1m3s
CI / Security Advisory Checks (push) Successful in 37s
Publish Images / Resolve Publish Metadata (push) Successful in 1s
CI / Tests (push) Successful in 8m46s
Publish Images / Publish web (arm64) (push) Successful in 3m20s
Publish Images / Publish Docker Dist Bundle (push) Failing after 9s
Publish Images / Publish web (amd64) (push) Successful in 52s
Publish Images / Build Rust Bundles (amd64) (push) Successful in 12m20s
Publish Images / Build Rust Bundles (arm64) (push) Successful in 12m30s
Publish Images / Publish agent (amd64) (push) Successful in 29s
Publish Images / Publish executor (amd64) (push) Successful in 35s
Publish Images / Publish api (amd64) (push) Successful in 42s
Publish Images / Publish notifier (amd64) (push) Successful in 35s
Publish Images / Publish agent (arm64) (push) Successful in 1m3s
Publish Images / Publish api (arm64) (push) Successful in 1m55s
Publish Images / Publish executor (arm64) (push) Successful in 2m1s
Publish Images / Publish notifier (arm64) (push) Successful in 1m54s
Publish Images / Publish manifest attune/agent (push) Successful in 10s
Publish Images / Publish manifest attune/api (push) Successful in 12s
Publish Images / Publish manifest attune/executor (push) Successful in 10s
Publish Images / Publish manifest attune/notifier (push) Successful in 9s
Publish Images / Publish manifest attune/web (push) Successful in 7s
2026-03-26 15:39:07 -05:00
f4ef823f43 fixing audit finding
Some checks failed
CI / Rustfmt (push) Successful in 21s
CI / Cargo Audit & Deny (push) Successful in 32s
CI / Security Blocking Checks (push) Successful in 9s
CI / Web Blocking Checks (push) Successful in 53s
CI / Web Advisory Checks (push) Successful in 34s
Publish Images / Resolve Publish Metadata (push) Successful in 1s
CI / Security Advisory Checks (push) Successful in 36s
CI / Clippy (push) Successful in 2m8s
Publish Images / Publish Docker Dist Bundle (push) Failing after 8s
Publish Images / Publish web (amd64) (push) Successful in 53s
Publish Images / Publish web (arm64) (push) Successful in 3m28s
CI / Tests (push) Successful in 9m20s
Publish Images / Build Rust Bundles (amd64) (push) Successful in 12m21s
Publish Images / Build Rust Bundles (arm64) (push) Successful in 12m23s
Publish Images / Publish agent (amd64) (push) Successful in 23s
Publish Images / Publish api (amd64) (push) Successful in 33s
Publish Images / Publish notifier (amd64) (push) Successful in 38s
Publish Images / Publish executor (amd64) (push) Successful in 54s
Publish Images / Publish agent (arm64) (push) Successful in 59s
Publish Images / Publish executor (arm64) (push) Successful in 1m55s
Publish Images / Publish api (arm64) (push) Successful in 2m2s
Publish Images / Publish notifier (arm64) (push) Successful in 2m3s
Publish Images / Publish manifest attune/agent (push) Successful in 10s
Publish Images / Publish manifest attune/executor (push) Successful in 19s
Publish Images / Publish manifest attune/api (push) Successful in 21s
Publish Images / Publish manifest attune/notifier (push) Successful in 12s
Publish Images / Publish manifest attune/web (push) Successful in 7s
2026-03-26 14:05:53 -05:00
ab7d31de2f fixing docker compose distribution 2026-03-26 14:04:57 -05:00
938c271ff5 distributable, please
Some checks failed
CI / Rustfmt (push) Successful in 22s
CI / Cargo Audit & Deny (push) Successful in 36s
CI / Security Blocking Checks (push) Successful in 6s
CI / Web Blocking Checks (push) Successful in 53s
CI / Web Advisory Checks (push) Successful in 34s
Publish Images / Resolve Publish Metadata (push) Successful in 1s
CI / Security Advisory Checks (push) Successful in 38s
CI / Clippy (push) Successful in 2m7s
Publish Images / Publish Docker Dist Bundle (push) Failing after 19s
Publish Images / Publish web (amd64) (push) Successful in 49s
Publish Images / Publish web (arm64) (push) Successful in 3m31s
CI / Tests (push) Successful in 8m48s
Publish Images / Build Rust Bundles (amd64) (push) Successful in 12m42s
Publish Images / Build Rust Bundles (arm64) (push) Successful in 12m19s
Publish Images / Publish agent (amd64) (push) Successful in 26s
Publish Images / Publish api (amd64) (push) Successful in 38s
Publish Images / Publish notifier (amd64) (push) Successful in 42s
Publish Images / Publish executor (amd64) (push) Successful in 46s
Publish Images / Publish agent (arm64) (push) Successful in 56s
Publish Images / Publish api (arm64) (push) Successful in 1m52s
Publish Images / Publish executor (arm64) (push) Successful in 2m2s
Publish Images / Publish notifier (arm64) (push) Successful in 2m3s
Publish Images / Publish manifest attune/agent (push) Successful in 6s
Publish Images / Publish manifest attune/api (push) Successful in 11s
Publish Images / Publish manifest attune/executor (push) Successful in 10s
Publish Images / Publish manifest attune/notifier (push) Successful in 8s
Publish Images / Publish manifest attune/web (push) Successful in 8s
2026-03-26 12:26:23 -05:00
da8055cb79 publishable docker compose?
Some checks failed
CI / Cargo Audit & Deny (push) Successful in 31s
CI / Rustfmt (push) Successful in 18s
CI / Security Blocking Checks (push) Successful in 6s
CI / Web Blocking Checks (push) Successful in 52s
CI / Web Advisory Checks (push) Successful in 31s
Publish Images / Resolve Publish Metadata (push) Successful in 2s
CI / Security Advisory Checks (push) Successful in 38s
CI / Clippy (push) Successful in 1m58s
Publish Images / Publish Docker Dist Bundle (push) Failing after 21s
Publish Images / Publish web (amd64) (push) Successful in 50s
Publish Images / Publish web (arm64) (push) Successful in 3m26s
CI / Tests (push) Successful in 9m1s
Publish Images / Build Rust Bundles (amd64) (push) Successful in 12m25s
Publish Images / Build Rust Bundles (arm64) (push) Successful in 12m42s
Publish Images / Publish agent (amd64) (push) Successful in 28s
Publish Images / Publish api (amd64) (push) Successful in 45s
Publish Images / Publish executor (amd64) (push) Successful in 46s
Publish Images / Publish notifier (amd64) (push) Successful in 49s
Publish Images / Publish agent (arm64) (push) Successful in 1m0s
Publish Images / Publish api (arm64) (push) Successful in 1m51s
Publish Images / Publish executor (arm64) (push) Successful in 2m1s
Publish Images / Publish notifier (arm64) (push) Successful in 2m1s
Publish Images / Publish manifest attune/agent (push) Successful in 6s
Publish Images / Publish manifest attune/api (push) Successful in 10s
Publish Images / Publish manifest attune/executor (push) Successful in 7s
Publish Images / Publish manifest attune/notifier (push) Successful in 9s
Publish Images / Publish manifest attune/web (push) Successful in 7s
2026-03-26 08:46:18 -05:00
03a239d22b manifest publish retries and more descriptive logs.
All checks were successful
CI / Rustfmt (push) Successful in 22s
CI / Cargo Audit & Deny (push) Successful in 34s
CI / Security Blocking Checks (push) Successful in 8s
CI / Web Blocking Checks (push) Successful in 52s
CI / Web Advisory Checks (push) Successful in 38s
Publish Images / Resolve Publish Metadata (push) Successful in 2s
CI / Clippy (push) Successful in 2m1s
CI / Security Advisory Checks (push) Successful in 1m24s
Publish Images / Publish web (amd64) (push) Successful in 46s
Publish Images / Publish web (arm64) (push) Successful in 3m23s
CI / Tests (push) Successful in 8m54s
Publish Images / Build Rust Bundles (amd64) (push) Successful in 12m27s
Publish Images / Build Rust Bundles (arm64) (push) Successful in 12m19s
Publish Images / Publish agent (amd64) (push) Successful in 23s
Publish Images / Publish api (amd64) (push) Successful in 37s
Publish Images / Publish executor (amd64) (push) Successful in 47s
Publish Images / Publish agent (arm64) (push) Successful in 1m1s
Publish Images / Publish notifier (amd64) (push) Successful in 40s
Publish Images / Publish api (arm64) (push) Successful in 1m51s
Publish Images / Publish executor (arm64) (push) Successful in 2m1s
Publish Images / Publish notifier (arm64) (push) Successful in 1m49s
Publish Images / Publish manifest attune/agent (push) Successful in 7s
Publish Images / Publish manifest attune/executor (push) Successful in 8s
Publish Images / Publish manifest attune/notifier (push) Successful in 9s
Publish Images / Publish manifest attune/api (push) Successful in 18s
Publish Images / Publish manifest attune/web (push) Successful in 8s
2026-03-26 07:40:07 -05:00
ba83958337 trying to fix manifest push
Some checks failed
CI / Rustfmt (push) Successful in 22s
CI / Cargo Audit & Deny (push) Successful in 35s
CI / Security Blocking Checks (push) Successful in 9s
CI / Web Blocking Checks (push) Successful in 51s
CI / Web Advisory Checks (push) Successful in 37s
Publish Images / Resolve Publish Metadata (push) Successful in 1s
CI / Clippy (push) Successful in 2m9s
CI / Security Advisory Checks (push) Successful in 1m25s
Publish Images / Publish web (amd64) (push) Successful in 52s
Publish Images / Publish web (arm64) (push) Successful in 3m27s
CI / Tests (push) Successful in 8m48s
Publish Images / Build Rust Bundles (amd64) (push) Successful in 12m50s
Publish Images / Build Rust Bundles (arm64) (push) Successful in 12m29s
Publish Images / Publish agent (amd64) (push) Successful in 26s
Publish Images / Publish api (amd64) (push) Successful in 37s
Publish Images / Publish executor (amd64) (push) Successful in 40s
Publish Images / Publish agent (arm64) (push) Successful in 1m2s
Publish Images / Publish notifier (amd64) (push) Successful in 38s
Publish Images / Publish executor (arm64) (push) Successful in 1m57s
Publish Images / Publish api (arm64) (push) Successful in 1m58s
Publish Images / Publish notifier (arm64) (push) Successful in 2m6s
Publish Images / Publish manifest attune/agent (push) Successful in 12s
Publish Images / Publish manifest attune/api (push) Successful in 11s
Publish Images / Publish manifest attune/notifier (push) Successful in 13s
Publish Images / Publish manifest attune/executor (push) Successful in 16s
Publish Images / Publish manifest attune/web (push) Failing after 37s
2026-03-25 17:29:27 -05:00
c11bc1a2bf trying to fix manifest push
Some checks failed
CI / Rustfmt (push) Successful in 23s
CI / Clippy (push) Successful in 2m6s
CI / Cargo Audit & Deny (push) Successful in 33s
CI / Web Blocking Checks (push) Successful in 52s
CI / Security Blocking Checks (push) Successful in 6s
CI / Web Advisory Checks (push) Successful in 36s
CI / Security Advisory Checks (push) Successful in 38s
Publish Images / Resolve Publish Metadata (push) Successful in 1s
Publish Images / Publish web (arm64) (push) Successful in 3m26s
CI / Tests (push) Successful in 8m52s
Publish Images / Publish web (amd64) (push) Successful in 1m8s
Publish Images / Build Rust Bundles (amd64) (push) Successful in 12m29s
Publish Images / Build Rust Bundles (arm64) (push) Successful in 12m46s
Publish Images / Publish agent (amd64) (push) Successful in 26s
Publish Images / Publish api (amd64) (push) Successful in 40s
Publish Images / Publish executor (amd64) (push) Successful in 39s
Publish Images / Publish agent (arm64) (push) Successful in 57s
Publish Images / Publish notifier (amd64) (push) Successful in 41s
Publish Images / Publish api (arm64) (push) Successful in 2m3s
Publish Images / Publish executor (arm64) (push) Successful in 2m2s
Publish Images / Publish notifier (arm64) (push) Successful in 1m57s
Publish Images / Publish manifest attune/api (push) Failing after 10s
Publish Images / Publish manifest attune/agent (push) Successful in 12s
Publish Images / Publish manifest attune/executor (push) Successful in 11s
Publish Images / Publish manifest attune/notifier (push) Successful in 11s
Publish Images / Publish manifest attune/web (push) Failing after 8s
2026-03-25 17:10:36 -05:00
eb82755137 trying different urls? not sure why publishing is only working for the arm64 builds
Some checks failed
CI / Rustfmt (push) Successful in 22s
CI / Security Blocking Checks (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Cargo Audit & Deny (push) Has been cancelled
CI / Web Advisory Checks (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Security Advisory Checks (push) Has been cancelled
CI / Web Blocking Checks (push) Has been cancelled
Publish Images / Resolve Publish Metadata (push) Successful in 1s
Publish Images / Publish web (amd64) (push) Successful in 45s
Publish Images / Publish web (arm64) (push) Successful in 3m19s
Publish Images / Build Rust Bundles (amd64) (push) Successful in 12m24s
Publish Images / Build Rust Bundles (arm64) (push) Successful in 12m43s
Publish Images / Publish agent (amd64) (push) Successful in 27s
Publish Images / Publish api (amd64) (push) Successful in 41s
Publish Images / Publish agent (arm64) (push) Successful in 1m0s
Publish Images / Publish notifier (amd64) (push) Successful in 40s
Publish Images / Publish executor (arm64) (push) Successful in 1m58s
Publish Images / Publish notifier (arm64) (push) Successful in 1m53s
Publish Images / Publish manifest attune/api (push) Has been skipped
Publish Images / Publish manifest attune/executor (push) Has been skipped
Publish Images / Publish manifest attune/notifier (push) Has been skipped
Publish Images / Publish manifest attune/web (push) Has been skipped
Publish Images / Publish executor (amd64) (push) Successful in 45s
Publish Images / Publish api (arm64) (push) Successful in 2m2s
Publish Images / Publish manifest attune/agent (push) Failing after 1s
2026-03-25 14:29:15 -05:00
058f392616 updating the publisher, again
Some checks failed
CI / Cargo Audit & Deny (push) Successful in 1m11s
CI / Rustfmt (push) Successful in 1m20s
CI / Security Blocking Checks (push) Successful in 9s
CI / Clippy (push) Successful in 2m1s
CI / Web Advisory Checks (push) Successful in 1m9s
CI / Web Blocking Checks (push) Successful in 1m26s
Publish Images / Resolve Publish Metadata (push) Successful in 1s
CI / Security Advisory Checks (push) Successful in 39s
Publish Images / Publish web (arm64) (push) Successful in 3m50s
CI / Tests (push) Successful in 9m4s
Publish Images / Build Rust Bundles (arm64) (push) Successful in 12m17s
Publish Images / Build Rust Bundles (amd64) (push) Failing after 12m21s
Publish Images / Publish api (arm64) (push) Has been skipped
Publish Images / Publish executor (arm64) (push) Has been skipped
Publish Images / Publish notifier (amd64) (push) Has been skipped
Publish Images / Publish executor (amd64) (push) Has been skipped
Publish Images / Publish notifier (arm64) (push) Has been skipped
Publish Images / Publish manifest attune/api (push) Has been skipped
Publish Images / Publish manifest attune/executor (push) Has been skipped
Publish Images / Publish manifest attune/notifier (push) Has been skipped
Publish Images / Publish manifest attune/web (push) Has been skipped
Publish Images / Publish web (amd64) (push) Failing after 47s
Publish Images / Publish agent (amd64) (push) Has been skipped
Publish Images / Publish api (amd64) (push) Has been skipped
Publish Images / Publish agent (arm64) (push) Has been skipped
Publish Images / Publish manifest attune/agent (push) Has been skipped
2026-03-25 13:10:44 -05:00
0264a66b5a renaming container artifacts and adding project linking stage
Some checks failed
CI / Rustfmt (push) Successful in 21s
CI / Clippy (push) Successful in 2m3s
CI / Cargo Audit & Deny (push) Successful in 34s
CI / Web Blocking Checks (push) Successful in 1m27s
CI / Security Blocking Checks (push) Successful in 15s
CI / Web Advisory Checks (push) Successful in 32s
CI / Security Advisory Checks (push) Successful in 1m25s
Publish Images / Resolve Publish Metadata (push) Successful in 1s
CI / Tests (push) Successful in 8m56s
Publish Images / Publish web (arm64) (push) Failing after 3m49s
Publish Images / Publish web (amd64) (push) Failing after 1m28s
Publish Images / Build Rust Bundles (amd64) (push) Failing after 12m21s
Publish Images / Build Rust Bundles (arm64) (push) Failing after 12m28s
Publish Images / Publish agent (amd64) (push) Has been skipped
Publish Images / Publish api (amd64) (push) Has been skipped
Publish Images / Publish agent (arm64) (push) Has been skipped
Publish Images / Publish api (arm64) (push) Has been skipped
Publish Images / Publish executor (amd64) (push) Has been skipped
Publish Images / Publish executor (arm64) (push) Has been skipped
Publish Images / Publish notifier (amd64) (push) Has been skipped
Publish Images / Publish notifier (arm64) (push) Has been skipped
Publish Images / Publish manifest attune/api (push) Has been skipped
Publish Images / Publish manifest attune/executor (push) Has been skipped
Publish Images / Publish manifest attune/agent (push) Has been skipped
Publish Images / Publish manifest attune/notifier (push) Has been skipped
Publish Images / Publish manifest attune/web (push) Has been skipped
2026-03-25 12:39:47 -05:00
542e72a454 fixing glibc version check
Some checks failed
CI / Clippy (push) Successful in 2m1s
CI / Rustfmt (push) Successful in 21s
CI / Cargo Audit & Deny (push) Successful in 32s
CI / Web Blocking Checks (push) Successful in 53s
CI / Security Blocking Checks (push) Successful in 8s
CI / Web Advisory Checks (push) Successful in 37s
CI / Security Advisory Checks (push) Successful in 36s
Publish Images / Resolve Publish Metadata (push) Successful in 2s
Publish Images / Publish web (arm64) (push) Successful in 3m39s
CI / Tests (push) Successful in 8m37s
Publish Images / Build Rust Bundles (amd64) (push) Successful in 12m21s
Publish Images / Build Rust Bundles (arm64) (push) Successful in 12m15s
Publish Images / Publish agent (amd64) (push) Successful in 26s
Publish Images / Publish api (amd64) (push) Successful in 39s
Publish Images / Publish executor (amd64) (push) Successful in 37s
Publish Images / Publish notifier (amd64) (push) Successful in 37s
Publish Images / Publish agent (arm64) (push) Successful in 1m34s
Publish Images / Publish executor (arm64) (push) Successful in 2m12s
Publish Images / Publish api (arm64) (push) Successful in 2m22s
Publish Images / Publish manifest attune-executor (push) Has been skipped
Publish Images / Publish manifest attune-notifier (push) Has been skipped
Publish Images / Publish manifest attune-web (push) Has been skipped
Publish Images / Publish notifier (arm64) (push) Successful in 2m10s
Publish Images / Publish web (amd64) (push) Successful in 47s
Publish Images / Publish manifest attune-agent (push) Failing after 2s
Publish Images / Publish manifest attune-api (push) Failing after 1s
2026-03-25 11:17:50 -05:00
a118563366 building? hopefully?
Some checks failed
CI / Rustfmt (push) Successful in 22s
CI / Clippy (push) Successful in 2m3s
CI / Cargo Audit & Deny (push) Successful in 32s
CI / Web Blocking Checks (push) Successful in 52s
CI / Security Blocking Checks (push) Successful in 8s
CI / Web Advisory Checks (push) Successful in 36s
CI / Security Advisory Checks (push) Successful in 43s
Publish Images / Resolve Publish Metadata (push) Successful in 2s
Publish Images / Publish web (arm64) (push) Failing after 3m53s
CI / Tests (push) Successful in 8m45s
Publish Images / Build Rust Bundles (amd64) (push) Failing after 8m57s
Publish Images / Publish web (amd64) (push) Successful in 48s
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 / Build Rust Bundles (arm64) (push) Has been cancelled
Publish Images / Publish notifier (arm64) (push) Has been cancelled
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 / Publish manifest attune-web (push) Has been cancelled
2026-03-25 10:52:07 -05:00
a057ad5db5 adjusting publish pipeline to cross-compile because rpis are slow
Some checks failed
CI / Rustfmt (push) Successful in 21s
CI / Clippy (push) Failing after 2m3s
CI / Cargo Audit & Deny (push) Successful in 33s
CI / Web Blocking Checks (push) Successful in 51s
CI / Security Blocking Checks (push) Successful in 5s
CI / Web Advisory Checks (push) Successful in 38s
CI / Security Advisory Checks (push) Successful in 36s
Publish Images / Resolve Publish Metadata (push) Successful in 1s
Publish Images / Publish web (arm64) (push) Successful in 3m34s
Publish Images / Build Rust Bundles (amd64) (push) Failing after 4m1s
CI / Tests (push) Successful in 8m47s
Publish Images / Publish web (amd64) (push) Failing after 46s
Publish Images / Build Rust Bundles (arm64) (push) Failing after 4m3s
Publish Images / Publish agent (arm64) (push) Has been skipped
Publish Images / Publish api (arm64) (push) Has been skipped
Publish Images / Publish agent (amd64) (push) Has been skipped
Publish Images / Publish api (amd64) (push) Has been skipped
Publish Images / Publish executor (arm64) (push) Has been skipped
Publish Images / Publish notifier (arm64) (push) Has been skipped
Publish Images / Publish executor (amd64) (push) Has been skipped
Publish Images / Publish notifier (amd64) (push) Has been skipped
Publish Images / Publish manifest attune-agent (push) Has been skipped
Publish Images / Publish manifest attune-api (push) Has been skipped
Publish Images / Publish manifest attune-executor (push) Has been skipped
Publish Images / Publish manifest attune-notifier (push) Has been skipped
Publish Images / Publish manifest attune-web (push) Has been skipped
2026-03-25 10:07:48 -05:00
8e273ec683 more adjustments to publisher 2026-03-25 08:14:06 -05:00
16f1c2f079 matching runner tags after changing runner tags
Some checks failed
CI / Rustfmt (push) Successful in 1m4s
CI / Clippy (push) Failing after 1m46s
CI / Cargo Audit & Deny (push) Successful in 34s
CI / Web Blocking Checks (push) Successful in 1m24s
CI / Security Blocking Checks (push) Successful in 8s
CI / Web Advisory Checks (push) Successful in 32s
CI / Security Advisory Checks (push) Successful in 1m26s
Publish Images / Resolve Publish Metadata (push) Successful in 1s
CI / Tests (push) Successful in 8m51s
Publish Images / Publish web (amd64) (push) Successful in 1m4s
Publish Images / Build Rust Bundles (amd64) (push) Successful in 10m59s
Publish Images / Build Rust Bundles (arm64) (push) Successful in 1h19m31s
Publish Images / Publish agent (amd64) (push) Failing after 14s
Publish Images / Publish executor (amd64) (push) Failing after 12s
Publish Images / Publish api (amd64) (push) Failing after 32s
Publish Images / Publish notifier (amd64) (push) Failing after 14s
Publish Images / Publish api (arm64) (push) Failing after 1m58s
Publish Images / Publish executor (arm64) (push) Failing after 49s
Publish Images / Publish notifier (arm64) (push) Failing after 48s
Publish Images / Publish web (arm64) (push) Successful in 3m47s
Publish Images / Publish agent (arm64) (push) Failing after 4m13s
Publish Images / Publish manifest attune-agent (push) Has been skipped
Publish Images / Publish manifest attune-api (push) Has been skipped
Publish Images / Publish manifest attune-executor (push) Has been skipped
Publish Images / Publish manifest attune-notifier (push) Has been skipped
Publish Images / Publish manifest attune-web (push) Has been skipped
2026-03-25 01:22:50 -05:00
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
506 changed files with 52489 additions and 7671 deletions

0
.codex_write_test Normal file
View File

View File

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

View File

@@ -9,19 +9,32 @@ on:
env:
CARGO_TERM_COLOR: always
RUST_MIN_STACK: 16777216
RUST_MIN_STACK: 67108864
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
RUSTUP_MAX_RETRIES: 10
# Gitea Actions runner tool cache. Actions like setup-node/setup-python can reuse this.
RUNNER_TOOL_CACHE: /toolcache
jobs:
rust-fmt:
name: Rustfmt
runs-on: ubuntu-latest
runs-on: build-amd64
steps:
- name: Checkout
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
uses: dtolnay/rust-toolchain@stable
with:
@@ -32,11 +45,22 @@ jobs:
rust-clippy:
name: Clippy
runs-on: ubuntu-latest
runs-on: build-amd64
steps:
- name: Checkout
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
uses: dtolnay/rust-toolchain@stable
with:
@@ -67,11 +91,22 @@ jobs:
rust-test:
name: Tests
runs-on: ubuntu-latest
runs-on: build-amd64
steps:
- name: Checkout
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
uses: dtolnay/rust-toolchain@stable
@@ -100,11 +135,22 @@ jobs:
rust-audit:
name: Cargo Audit & Deny
runs-on: ubuntu-latest
runs-on: build-amd64
steps:
- name: Checkout
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
uses: dtolnay/rust-toolchain@stable
@@ -124,9 +170,8 @@ jobs:
with:
path: |
~/.cargo/bin/cargo-binstall
~/.cargo/bin/cargo-audit
~/.cargo/bin/cargo-deny
key: cargo-security-tools-v1
key: cargo-security-tools-v2
- name: Install cargo-binstall
run: |
@@ -136,18 +181,14 @@ jobs:
- name: Install security tools (pre-built binaries)
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
- name: Cargo Audit
run: cargo audit
- name: Cargo Deny
run: cargo deny check
web-blocking:
name: Web Blocking Checks
runs-on: ubuntu-latest
runs-on: build-amd64
defaults:
run:
working-directory: web
@@ -176,7 +217,7 @@ jobs:
security-blocking:
name: Security Blocking Checks
runs-on: ubuntu-latest
runs-on: build-amd64
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -209,7 +250,7 @@ jobs:
web-advisory:
name: Web Advisory Checks
runs-on: ubuntu-latest
runs-on: build-amd64
continue-on-error: true
defaults:
run:
@@ -238,7 +279,7 @@ jobs:
security-advisory:
name: Security Advisory Checks
runs-on: ubuntu-latest
runs-on: build-amd64
continue-on-error: true
steps:
- name: Checkout

1062
.gitea/workflows/publish.yml Normal file

File diff suppressed because it is too large Load Diff

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

8
.gitignore vendored
View File

@@ -1,6 +1,5 @@
# Rust
target/
Cargo.lock
**/*.rs.bk
*.pdb
@@ -12,6 +11,7 @@ Cargo.lock
# Configuration files (keep *.example.yaml)
config.yaml
config.*.yaml
!docker/distributable/config.docker.yaml
!config.example.yaml
!config.development.yaml
!config.test.yaml
@@ -36,6 +36,7 @@ logs/
# Build artifacts
dist/
build/
artifacts/
# Testing
coverage/
@@ -79,3 +80,8 @@ docker-compose.override.yml
*.pid
packs.examples/
packs.external/
codex/
# Compiled pack binaries (built via Docker or build-pack-binaries.sh)
packs/core/sensors/attune-core-timer-sensor

View File

@@ -2,6 +2,5 @@ target/
web/dist/
web/node_modules/
web/src/api/
packs/
packs.dev/
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}}

7105
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

156
Makefile
View File

@@ -3,7 +3,12 @@
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-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 docker-build-agent-arm64 docker-build-agent-all \
run-agent run-agent-release \
docker-up-agent docker-down-agent \
docker-build-pack-binaries docker-build-pack-binaries-arm64 docker-build-pack-binaries-all
# Default target
help:
@@ -19,13 +24,18 @@ help:
@echo " make test - Run all tests"
@echo " make test-common - Run tests for common library"
@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 ""
@echo "Code Quality:"
@echo " make fmt - Format all code"
@echo " make fmt-check - Verify formatting without changing files"
@echo " make clippy - Run linter"
@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 "Running Services:"
@echo " make run-api - Run API service"
@@ -54,6 +64,21 @@ help:
@echo " make docker-up - Start services with docker compose"
@echo " make docker-down - Stop services"
@echo ""
@echo "Agent (Universal Worker):"
@echo " make build-agent - Build statically-linked agent binary (musl)"
@echo " make docker-build-agent - Build agent Docker image (amd64, default)"
@echo " make docker-build-agent-arm64 - Build agent Docker image (arm64)"
@echo " make docker-build-agent-all - Build agent Docker images (amd64 + arm64)"
@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 "Pack Binaries:"
@echo " make docker-build-pack-binaries - Build pack binaries Docker image (amd64, default)"
@echo " make docker-build-pack-binaries-arm64 - Build pack binaries Docker image (arm64)"
@echo " make docker-build-pack-binaries-all - Build pack binaries Docker images (amd64 + arm64)"
@echo ""
@echo "Development:"
@echo " make watch - Watch and rebuild on changes"
@echo " make install-tools - Install development tools"
@@ -63,7 +88,7 @@ help:
@echo ""
# Increase rustc stack size to prevent SIGSEGV during compilation
export RUST_MIN_STACK := 16777216
export RUST_MIN_STACK:=67108864
# Building
build:
@@ -88,13 +113,18 @@ test-api:
test-verbose:
cargo test -- --nocapture --test-threads=1
test-integration:
test-integration: test-integration-api
@echo "Setting up test database..."
@make db-test-setup
@echo "Running integration tests..."
@echo "Running common integration tests..."
cargo test --test '*' -p attune-common -- --test-threads=1
@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
@echo "All tests with database complete"
@@ -105,6 +135,9 @@ check:
fmt:
cargo fmt --all
fmt-check:
cargo fmt --all -- --check
clippy:
cargo clippy --all-features -- -D warnings
@@ -213,38 +246,86 @@ docker-build-api:
docker-build-web:
docker compose build web
# Build worker images
docker-build-workers: docker-build-worker-base docker-build-worker-python docker-build-worker-node docker-build-worker-full
@echo "✅ All worker images built successfully"
# Agent binary (statically-linked for injection into any container)
AGENT_RUST_TARGET ?= x86_64-unknown-linux-musl
docker-build-worker-base:
@echo "Building base worker (shell only)..."
DOCKER_BUILDKIT=1 docker build --target worker-base -t attune-worker:base -f docker/Dockerfile.worker .
@echo "✅ Base worker image built: attune-worker:base"
# Pack binaries (statically-linked for packs volume)
PACK_BINARIES_RUST_TARGET ?= x86_64-unknown-linux-musl
docker-build-worker-python:
@echo "Building Python worker (shell + python)..."
DOCKER_BUILDKIT=1 docker build --target worker-python -t attune-worker:python -f docker/Dockerfile.worker .
@echo "✅ Python worker image built: attune-worker:python"
build-agent:
@echo "Installing musl target (if not already installed)..."
rustup target add $(AGENT_RUST_TARGET) 2>/dev/null || true
@echo "Building statically-linked worker and sensor agent binaries..."
SQLX_OFFLINE=true cargo build --release --target $(AGENT_RUST_TARGET) --bin attune-agent --bin attune-sensor-agent
strip target/$(AGENT_RUST_TARGET)/release/attune-agent
strip target/$(AGENT_RUST_TARGET)/release/attune-sensor-agent
@echo "✅ Agent binaries built:"
@echo " - target/$(AGENT_RUST_TARGET)/release/attune-agent"
@echo " - target/$(AGENT_RUST_TARGET)/release/attune-sensor-agent"
@ls -lh target/$(AGENT_RUST_TARGET)/release/attune-agent
@ls -lh target/$(AGENT_RUST_TARGET)/release/attune-sensor-agent
docker-build-worker-node:
@echo "Building Node.js worker (shell + node)..."
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-agent:
@echo "Building agent Docker image ($(AGENT_RUST_TARGET))..."
DOCKER_BUILDKIT=1 docker buildx build --build-arg RUST_TARGET=$(AGENT_RUST_TARGET) --target agent-init -f docker/Dockerfile.agent -t attune-agent:latest .
@echo "✅ Agent image built: attune-agent:latest ($(AGENT_RUST_TARGET))"
docker-build-worker-full:
@echo "Building full worker (all runtimes)..."
DOCKER_BUILDKIT=1 docker build --target worker-full -t attune-worker:full -f docker/Dockerfile.worker .
@echo "✅ Full worker image built: attune-worker:full"
docker-build-agent-arm64:
@echo "Building arm64 agent Docker image..."
DOCKER_BUILDKIT=1 docker buildx build --build-arg RUST_TARGET=aarch64-unknown-linux-musl --target agent-init -f docker/Dockerfile.agent -t attune-agent:arm64 .
@echo "✅ Agent image built: attune-agent:arm64"
docker-build-agent-all:
@echo "Building agent Docker images for all architectures..."
$(MAKE) docker-build-agent
$(MAKE) docker-build-agent-arm64
@echo "✅ All agent images built: attune-agent:latest (amd64), attune-agent:arm64"
run-agent:
cargo run --bin attune-agent
run-agent-release:
cargo run --bin attune-agent --release
# Pack binaries (statically-linked for packs volume)
docker-build-pack-binaries:
@echo "Building pack binaries Docker image ($(PACK_BINARIES_RUST_TARGET))..."
DOCKER_BUILDKIT=1 docker buildx build --build-arg RUST_TARGET=$(PACK_BINARIES_RUST_TARGET) --target pack-binaries-init -f docker/Dockerfile.pack-binaries -t attune-pack-builder:latest .
@echo "✅ Pack binaries image built: attune-pack-builder:latest ($(PACK_BINARIES_RUST_TARGET))"
docker-build-pack-binaries-arm64:
@echo "Building arm64 pack binaries Docker image..."
DOCKER_BUILDKIT=1 docker buildx build --build-arg RUST_TARGET=aarch64-unknown-linux-musl --target pack-binaries-init -f docker/Dockerfile.pack-binaries -t attune-pack-builder:arm64 .
@echo "✅ Pack binaries image built: attune-pack-builder:arm64"
docker-build-pack-binaries-all:
@echo "Building pack binaries Docker images for all architectures..."
$(MAKE) docker-build-pack-binaries
$(MAKE) docker-build-pack-binaries-arm64
@echo "✅ All pack binary images built: attune-pack-builder:latest (amd64), attune-pack-builder:arm64"
run-sensor-agent:
cargo run --bin attune-sensor-agent
run-sensor-agent-release:
cargo run --bin attune-sensor-agent --release
docker-up:
@echo "Starting all services with Docker Compose..."
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:
@echo "Stopping all services..."
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:
@echo "Stopping all services and removing volumes (WARNING: deletes data)..."
docker compose down -v
@@ -316,14 +397,9 @@ coverage:
update:
cargo update
# Audit dependencies for security issues
# Ignored advisories:
# RUSTSEC-2023-0071: rsa via sqlx-mysql (we only use postgres, no upstream fix)
# RUSTSEC-2025-0134: rustls-pemfile via lapin TLS stack (no alternative)
AUDIT_IGNORE = --ignore RUSTSEC-2023-0071 --ignore RUSTSEC-2025-0134
# Audit dependencies for security issues (ignores configured in deny.toml)
audit:
cargo audit $(AUDIT_IGNORE)
cargo deny check advisories
deny:
cargo deny check
@@ -332,7 +408,6 @@ ci-rust:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features
cargo audit $(AUDIT_IGNORE)
cargo deny check
ci-web-blocking:
@@ -341,6 +416,11 @@ ci-web-blocking:
cd web && npm run typecheck
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:
cd web && npm ci
cd web && npm run knip
@@ -381,9 +461,15 @@ licenses:
cargo license --json > licenses.json
@echo "License information saved to licenses.json"
# All-in-one check before committing
pre-commit: fmt clippy test
@echo "✅ All checks passed! Ready to commit."
# Blocking checks run by the git pre-commit hook after formatting.
# Keep the local web step fast; full production builds stay in CI.
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: 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__REDIS__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
encryption_key: test-encryption-key-32-chars-okay
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_base_dir: ./packs
@@ -109,3 +125,8 @@ executor:
scheduled_timeout: 120 # 2 minutes (faster feedback in dev)
timeout_check_interval: 30 # Check every 30 seconds
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_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)
# Uncomment and configure if running worker processes
# worker:

View File

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

View File

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

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

@@ -0,0 +1,504 @@
//! 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 url = config.url.as_deref().unwrap_or_default();
let (conn, ldap) = LdapConnAsync::with_settings(settings, 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().unwrap_or_default(),
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::{
extract::{Request, State},
http::{header::AUTHORIZATION, StatusCode},
http::{header::AUTHORIZATION, HeaderMap, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
@@ -14,6 +14,8 @@ use attune_common::auth::jwt::{
extract_token_from_header, validate_token, Claims, JwtConfig, TokenType,
};
use super::oidc::{cookie_authenticated_user, ACCESS_COOKIE_NAME};
/// Authentication middleware state
#[derive(Clone)]
pub struct AuthMiddleware {
@@ -50,21 +52,7 @@ pub async fn require_auth(
mut request: Request,
next: Next,
) -> Result<Response, AuthError> {
// Extract Authorization header
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,
})?;
let claims = extract_claims(request.headers(), &auth.jwt_config)?;
// Add claims to request extensions
request
@@ -90,22 +78,13 @@ impl axum::extract::FromRequestParts<crate::state::SharedState> for RequireAuth
return Ok(RequireAuth(user.clone()));
}
// Otherwise, extract and validate token directly from header
// Extract Authorization header
let auth_header = parts
.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 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,
})?;
let claims = if let Some(user) =
cookie_authenticated_user(&parts.headers, state).map_err(map_cookie_auth_error)?
{
user.claims
} else {
extract_claims(&parts.headers, &state.jwt_config)?
};
// Allow access, sensor, and execution-scoped tokens
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
#[derive(Debug)]
pub enum AuthError {

View File

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

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

@@ -0,0 +1,803 @@
//! 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_str = oidc.redirect_uri.clone().unwrap_or_default();
let redirect_uri = RedirectUrl::new(redirect_uri_str).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_id = oidc.client_id.clone().unwrap_or_default();
let client = CoreClient::from_provider_metadata(
discovery.metadata.clone(),
ClientId::new(client_id),
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_str = oidc.redirect_uri.clone().unwrap_or_default();
let redirect_uri = RedirectUrl::new(redirect_uri_str).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_id = oidc.client_id.clone().unwrap_or_default();
let client = CoreClient::from_provider_metadata(
discovery.metadata.clone(),
ClientId::new(client_id),
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.as_deref().unwrap_or_default());
}
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_url = oidc.discovery_url.as_deref().unwrap_or_default();
let discovery = reqwest::get(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_deref().unwrap_or_default()]);
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()
.and_then(|oidc| oidc.redirect_uri.as_deref())
.map(|uri| 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,
/// Action description
#[validate(length(min = 1))]
#[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)
#[validate(length(min = 1, max = 1024))]
@@ -63,7 +62,6 @@ pub struct UpdateActionRequest {
pub label: Option<String>,
/// Action description
#[validate(length(min = 1))]
#[schema(example = "Posts a message to a Slack channel with enhanced features")]
pub description: Option<String>,
@@ -76,9 +74,8 @@ pub struct UpdateActionRequest {
#[schema(example = 1)]
pub runtime: Option<i64>,
/// Optional semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0")
#[schema(example = ">=3.12", nullable = true)]
pub runtime_version_constraint: Option<Option<String>>,
/// Optional semver version constraint patch for the runtime.
pub runtime_version_constraint: Option<RuntimeVersionConstraintPatch>,
/// Parameter schema (StackStorm-style with inline required/secret)
#[schema(value_type = Object, nullable = true)]
@@ -89,6 +86,14 @@ pub struct UpdateActionRequest {
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
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ActionResponse {
@@ -114,7 +119,7 @@ pub struct ActionResponse {
/// Action description
#[schema(example = "Posts a message to a Slack channel")]
pub description: String,
pub description: Option<String>,
/// Entry point
#[schema(example = "/actions/slack/post_message.py")]
@@ -176,7 +181,7 @@ pub struct ActionSummary {
/// Action description
#[schema(example = "Posts a message to a Slack channel")]
pub description: String,
pub description: Option<String>,
/// Entry point
#[schema(example = "/actions/slack/post_message.py")]
@@ -314,7 +319,7 @@ mod tests {
r#ref: "".to_string(), // Invalid: empty
pack_ref: "test-pack".to_string(),
label: "Test Action".to_string(),
description: "Test description".to_string(),
description: Some("Test description".to_string()),
entrypoint: "/actions/test.py".to_string(),
runtime: None,
runtime_version_constraint: None,
@@ -331,7 +336,7 @@ mod tests {
r#ref: "test.action".to_string(),
pack_ref: "test-pack".to_string(),
label: "Test Action".to_string(),
description: "Test description".to_string(),
description: Some("Test description".to_string()),
entrypoint: "/actions/test.py".to_string(),
runtime: None,
runtime_version_constraint: None,

View File

@@ -97,19 +97,41 @@ pub struct UpdateArtifactRequest {
pub retention_limit: Option<i32>,
/// Updated name
pub name: Option<String>,
pub name: Option<ArtifactStringPatch>,
/// Updated description
pub description: Option<String>,
pub description: Option<ArtifactStringPatch>,
/// Updated content type
pub content_type: Option<String>,
pub content_type: Option<ArtifactStringPatch>,
/// Updated execution ID (re-links artifact to a different execution)
pub execution: Option<i64>,
/// Updated execution patch (set a new execution ID or clear the link)
pub execution: Option<ArtifactExecutionPatch>,
/// 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

View File

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

View File

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

View File

@@ -11,7 +11,9 @@ pub mod history;
pub mod inquiry;
pub mod key;
pub mod pack;
pub mod permission;
pub mod rule;
pub mod runtime;
pub mod trigger;
pub mod webhook;
pub mod workflow;
@@ -28,8 +30,8 @@ pub use artifact::{
CreateVersionJsonRequest, SetDataRequest, UpdateArtifactRequest,
};
pub use auth::{
ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest, RegisterRequest,
TokenResponse,
AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest,
RefreshTokenRequest, RegisterRequest, TokenResponse,
};
pub use common::{
ApiResponse, PaginatedResponse, PaginationMeta, PaginationParams, SuccessResponse,
@@ -48,7 +50,14 @@ pub use inquiry::{
};
pub use key::{CreateKeyRequest, KeyQueryParams, KeyResponse, KeySummary, UpdateKeyRequest};
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 runtime::{CreateRuntimeRequest, RuntimeResponse, RuntimeSummary, UpdateRuntimeRequest};
pub use trigger::{
CreateSensorRequest, CreateTriggerRequest, SensorResponse, SensorSummary, TriggerResponse,
TriggerSummary, UpdateSensorRequest, UpdateTriggerRequest,

View File

@@ -129,7 +129,7 @@ pub struct UpdatePackRequest {
/// Pack description
#[schema(example = "Enhanced Slack integration with new features")]
pub description: Option<String>,
pub description: Option<PackDescriptionPatch>,
/// Pack version
#[validate(length(min = 1, max = 50))]
@@ -165,6 +165,13 @@ pub struct UpdatePackRequest {
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
#[derive(Debug, Clone, Serialize, ToSchema)]
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,
/// Rule description
#[validate(length(min = 1))]
#[schema(example = "Send Slack notification when an error occurs")]
pub description: String,
pub description: Option<String>,
/// Action reference to execute when rule matches
#[validate(length(min = 1, max = 255))]
@@ -69,7 +68,6 @@ pub struct UpdateRuleRequest {
pub label: Option<String>,
/// Rule description
#[validate(length(min = 1))]
#[schema(example = "Enhanced error notification with filtering")]
pub description: Option<String>,
@@ -115,7 +113,7 @@ pub struct RuleResponse {
/// Rule description
#[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)
#[schema(example = 1)]
@@ -183,7 +181,7 @@ pub struct RuleSummary {
/// Rule description
#[schema(example = "Send Slack notification when an error occurs")]
pub description: String,
pub description: Option<String>,
/// Action reference
#[schema(example = "slack.post_message")]
@@ -297,7 +295,7 @@ mod tests {
r#ref: "".to_string(), // Invalid: empty
pack_ref: "test-pack".to_string(),
label: "Test Rule".to_string(),
description: "Test description".to_string(),
description: Some("Test description".to_string()),
action_ref: "test.action".to_string(),
trigger_ref: "test.trigger".to_string(),
conditions: default_empty_object(),
@@ -315,7 +313,7 @@ mod tests {
r#ref: "test.rule".to_string(),
pack_ref: "test-pack".to_string(),
label: "Test Rule".to_string(),
description: "Test description".to_string(),
description: Some("Test description".to_string()),
action_ref: "test.action".to_string(),
trigger_ref: "test.trigger".to_string(),
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
#[schema(example = "Updated webhook trigger description")]
pub description: Option<String>,
pub description: Option<TriggerStringPatch>,
/// Parameter schema (StackStorm-style with inline required/secret)
#[schema(value_type = Object, nullable = true)]
pub param_schema: Option<JsonValue>,
pub param_schema: Option<TriggerJsonPatch>,
/// Output schema
#[schema(value_type = Object, nullable = true)]
pub out_schema: Option<JsonValue>,
pub out_schema: Option<TriggerJsonPatch>,
/// Whether the trigger is enabled
#[schema(example = true)]
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
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct TriggerResponse {
@@ -189,9 +203,8 @@ pub struct CreateSensorRequest {
pub label: String,
/// Sensor description
#[validate(length(min = 1))]
#[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)
#[validate(length(min = 1, max = 1024))]
@@ -233,7 +246,6 @@ pub struct UpdateSensorRequest {
pub label: Option<String>,
/// Sensor description
#[validate(length(min = 1))]
#[schema(example = "Enhanced CPU monitoring with alerts")]
pub description: Option<String>,
@@ -244,13 +256,20 @@ pub struct UpdateSensorRequest {
/// Parameter schema (StackStorm-style with inline required/secret)
#[schema(value_type = Object, nullable = true)]
pub param_schema: Option<JsonValue>,
pub param_schema: Option<SensorJsonPatch>,
/// Whether the sensor is enabled
#[schema(example = false)]
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
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SensorResponse {
@@ -276,7 +295,7 @@ pub struct SensorResponse {
/// Sensor description
#[schema(example = "Monitors CPU usage and generates events")]
pub description: String,
pub description: Option<String>,
/// Entry point
#[schema(example = "/sensors/monitoring/cpu_monitor.py")]
@@ -336,7 +355,7 @@ pub struct SensorSummary {
/// Sensor description
#[schema(example = "Monitors CPU usage and generates events")]
pub description: String,
pub description: Option<String>,
/// Trigger reference
#[schema(example = "monitoring.cpu_threshold")]
@@ -478,7 +497,7 @@ mod tests {
r#ref: "test.sensor".to_string(),
pack_ref: "test-pack".to_string(),
label: "Test Sensor".to_string(),
description: "Test description".to_string(),
description: Some("Test description".to_string()),
entrypoint: "/sensors/test.py".to_string(),
runtime_ref: "python3".to_string(),
trigger_ref: "test.trigger".to_string(),

View File

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

View File

@@ -115,6 +115,10 @@ async fn mq_reconnect_loop(state: Arc<AppState>, mq_url: String) {
#[tokio::main]
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
tracing_subscriber::fmt()
.with_target(false)

View File

@@ -10,8 +10,8 @@ use crate::dto::{
ActionResponse, ActionSummary, CreateActionRequest, QueueStatsResponse, UpdateActionRequest,
},
auth::{
ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest,
RegisterRequest, TokenResponse,
AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest,
RefreshTokenRequest, RegisterRequest, TokenResponse,
},
common::{ApiResponse, PaginatedResponse, PaginationMeta, SuccessResponse},
event::{EnforcementResponse, EnforcementSummary, EventResponse, EventSummary},
@@ -26,7 +26,15 @@ use crate::dto::{
PackWorkflowSyncResponse, PackWorkflowValidationResponse, RegisterPackRequest,
UpdatePackRequest, WorkflowSyncResult,
},
permission::{
CreateIdentityRequest, CreateIdentityRoleAssignmentRequest,
CreatePermissionAssignmentRequest, CreatePermissionSetRoleAssignmentRequest,
IdentityResponse, IdentityRoleAssignmentResponse, IdentitySummary,
PermissionAssignmentResponse, PermissionSetRoleAssignmentResponse, PermissionSetSummary,
UpdateIdentityRequest,
},
rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest},
runtime::{CreateRuntimeRequest, RuntimeResponse, RuntimeSummary, UpdateRuntimeRequest},
trigger::{
CreateSensorRequest, CreateTriggerRequest, SensorResponse, SensorSummary, TriggerResponse,
TriggerSummary, UpdateSensorRequest, UpdateTriggerRequest,
@@ -63,7 +71,9 @@ use crate::dto::{
crate::routes::health::liveness,
// Authentication
crate::routes::auth::auth_settings,
crate::routes::auth::login,
crate::routes::auth::ldap_login,
crate::routes::auth::register,
crate::routes::auth::refresh_token,
crate::routes::auth::get_current_user,
@@ -92,6 +102,14 @@ use crate::dto::{
crate::routes::actions::delete_action,
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
crate::routes::triggers::list_triggers,
crate::routes::triggers::list_enabled_triggers,
@@ -160,6 +178,23 @@ use crate::dto::{
crate::routes::keys::update_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
crate::routes::workflows::list_workflows,
crate::routes::workflows::list_workflows_by_pack,
@@ -173,15 +208,21 @@ use crate::dto::{
crate::routes::webhooks::disable_webhook,
crate::routes::webhooks::regenerate_webhook_key,
crate::routes::webhooks::receive_webhook,
// Agent
crate::routes::agent::download_agent_binary,
crate::routes::agent::agent_info,
),
components(
schemas(
// Common types
ApiResponse<TokenResponse>,
ApiResponse<AuthSettingsResponse>,
ApiResponse<CurrentUserResponse>,
ApiResponse<PackResponse>,
ApiResponse<PackInstallResponse>,
ApiResponse<ActionResponse>,
ApiResponse<RuntimeResponse>,
ApiResponse<TriggerResponse>,
ApiResponse<SensorResponse>,
ApiResponse<RuleResponse>,
@@ -190,10 +231,13 @@ use crate::dto::{
ApiResponse<EnforcementResponse>,
ApiResponse<InquiryResponse>,
ApiResponse<KeyResponse>,
ApiResponse<IdentityResponse>,
ApiResponse<PermissionAssignmentResponse>,
ApiResponse<WorkflowResponse>,
ApiResponse<QueueStatsResponse>,
PaginatedResponse<PackSummary>,
PaginatedResponse<ActionSummary>,
PaginatedResponse<RuntimeSummary>,
PaginatedResponse<TriggerSummary>,
PaginatedResponse<SensorSummary>,
PaginatedResponse<RuleSummary>,
@@ -202,12 +246,14 @@ use crate::dto::{
PaginatedResponse<EnforcementSummary>,
PaginatedResponse<InquirySummary>,
PaginatedResponse<KeySummary>,
PaginatedResponse<IdentitySummary>,
PaginatedResponse<WorkflowSummary>,
PaginationMeta,
SuccessResponse,
// Auth DTOs
LoginRequest,
crate::routes::auth::LdapLoginRequest,
RegisterRequest,
RefreshTokenRequest,
ChangePasswordRequest,
@@ -233,6 +279,25 @@ use crate::dto::{
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
CreateActionRequest,
UpdateActionRequest,
@@ -293,6 +358,10 @@ use crate::dto::{
WebhookReceiverRequest,
WebhookReceiverResponse,
ApiResponse<WebhookReceiverResponse>,
// Agent DTOs
crate::routes::agent::AgentBinaryInfo,
crate::routes::agent::AgentArchInfo,
)
),
modifiers(&SecurityAddon),
@@ -311,6 +380,7 @@ use crate::dto::{
(name = "secrets", description = "Secret management endpoints"),
(name = "workflows", description = "Workflow management endpoints"),
(name = "webhooks", description = "Webhook management and receiver endpoints"),
(name = "agent", description = "Agent binary distribution endpoints"),
)
)]
pub struct ApiDoc;
@@ -393,18 +463,57 @@ mod tests {
// We have 57 unique paths with 81 total operations (HTTP methods)
// This test ensures we don't accidentally remove endpoints
assert!(
path_count >= 57,
"Expected at least 57 unique API paths, found {}",
path_count >= 59,
"Expected at least 59 unique API paths, found {}",
path_count
);
assert!(
operation_count >= 81,
"Expected at least 81 API operations, found {}",
operation_count >= 83,
"Expected at least 83 API operations, found {}",
operation_count
);
println!("Total API paths: {}", path_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 validator::Validate;
use attune_common::rbac::{Action, AuthorizationContext, Resource};
use attune_common::repositories::{
action::{ActionRepository, ActionSearchFilters, CreateActionInput, UpdateActionInput},
pack::PackRepository,
queue_stats::QueueStatsRepository,
Create, Delete, FindByRef, Update,
Create, Delete, FindByRef, Patch, Update,
};
use crate::{
auth::middleware::RequireAuth,
authz::{AuthorizationCheck, AuthorizationService},
dto::{
action::{
ActionResponse, ActionSummary, CreateActionRequest, QueueStatsResponse,
UpdateActionRequest,
RuntimeVersionConstraintPatch, UpdateActionRequest,
},
common::{PaginatedResponse, PaginationParams},
ApiResponse, SuccessResponse,
@@ -153,7 +155,7 @@ pub async fn get_action(
)]
pub async fn create_action(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
Json(request): Json<CreateActionRequest>,
) -> ApiResult<impl IntoResponse> {
// Validate request
@@ -175,6 +177,26 @@ pub async fn create_action(
.await?
.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)
// 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(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
Path(action_ref): Path<String>,
Json(request): Json<UpdateActionRequest>,
) -> ApiResult<impl IntoResponse> {
@@ -231,13 +253,37 @@ pub async fn update_action(
.await?
.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
let update_input = UpdateActionInput {
label: request.label,
description: request.description,
description: request.description.map(Patch::Set),
entrypoint: request.entrypoint,
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,
out_schema: request.out_schema,
parameter_delivery: None,
@@ -269,7 +315,7 @@ pub async fn update_action(
)]
pub async fn delete_action(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
Path(action_ref): Path<String>,
) -> ApiResult<impl IntoResponse> {
// Check if action exists
@@ -277,6 +323,27 @@ pub async fn delete_action(
.await?
.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
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,
CreateArtifactVersionInput, UpdateArtifactInput,
},
Create, Delete, FindById, FindByRef, Update,
Create, Delete, FindById, FindByRef, Patch, Update,
};
use crate::{
auth::middleware::RequireAuth,
auth::{jwt::TokenType, middleware::AuthenticatedUser, middleware::RequireAuth},
authz::{AuthorizationCheck, AuthorizationService},
dto::{
artifact::{
AllocateFileVersionByRefRequest, AppendProgressRequest, ArtifactQueryParams,
ArtifactResponse, ArtifactSummary, ArtifactVersionResponse, ArtifactVersionSummary,
AllocateFileVersionByRefRequest, AppendProgressRequest, ArtifactExecutionPatch,
ArtifactJsonPatch, ArtifactQueryParams, ArtifactResponse, ArtifactStringPatch,
ArtifactSummary, ArtifactVersionResponse, ArtifactVersionSummary,
CreateArtifactRequest, CreateFileVersionRequest, CreateVersionJsonRequest,
SetDataRequest, UpdateArtifactRequest,
},
@@ -54,6 +56,7 @@ use crate::{
middleware::{ApiError, ApiResult},
state::AppState,
};
use attune_common::rbac::{Action, AuthorizationContext, Resource};
// ============================================================================
// Artifact CRUD
@@ -71,7 +74,7 @@ use crate::{
security(("bearer_auth" = []))
)]
pub async fn list_artifacts(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Query(query): Query<ArtifactQueryParams>,
) -> ApiResult<impl IntoResponse> {
@@ -87,8 +90,16 @@ pub async fn list_artifacts(
};
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 {
page: query.page,
@@ -112,7 +123,7 @@ pub async fn list_artifacts(
security(("bearer_auth" = []))
)]
pub async fn get_artifact(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
@@ -120,6 +131,10 @@ pub async fn get_artifact(
.await?
.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((
StatusCode::OK,
Json(ApiResponse::new(ArtifactResponse::from(artifact))),
@@ -139,7 +154,7 @@ pub async fn get_artifact(
security(("bearer_auth" = []))
)]
pub async fn get_artifact_by_ref(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path(artifact_ref): Path<String>,
) -> ApiResult<impl IntoResponse> {
@@ -147,6 +162,10 @@ pub async fn get_artifact_by_ref(
.await?
.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((
StatusCode::OK,
Json(ApiResponse::new(ArtifactResponse::from(artifact))),
@@ -167,7 +186,7 @@ pub async fn get_artifact_by_ref(
security(("bearer_auth" = []))
)]
pub async fn create_artifact(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Json(request): Json<CreateArtifactRequest>,
) -> 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 {
r#ref: request.r#ref,
scope: request.scope,
@@ -239,16 +268,18 @@ pub async fn create_artifact(
security(("bearer_auth" = []))
)]
pub async fn update_artifact(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
Json(request): Json<UpdateArtifactRequest>,
) -> ApiResult<impl IntoResponse> {
// Verify artifact exists
ArtifactRepository::find_by_id(&state.db, id)
let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
let input = UpdateArtifactInput {
r#ref: None, // Ref is immutable after creation
scope: request.scope,
@@ -257,12 +288,27 @@ pub async fn update_artifact(
visibility: request.visibility,
retention_policy: request.retention_policy,
retention_limit: request.retention_limit,
name: request.name,
description: request.description,
content_type: request.content_type,
name: request.name.map(|patch| match patch {
ArtifactStringPatch::Set(value) => Patch::Set(value),
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
execution: request.execution.map(Some),
data: request.data,
execution: request.execution.map(|patch| match patch {
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?;
@@ -289,7 +335,7 @@ pub async fn update_artifact(
security(("bearer_auth" = []))
)]
pub async fn delete_artifact(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
@@ -297,6 +343,8 @@ pub async fn delete_artifact(
.await?
.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
let file_versions =
ArtifactVersionRepository::find_file_versions_by_artifact(&state.db, id).await?;
@@ -339,11 +387,17 @@ pub async fn delete_artifact(
security(("bearer_auth" = []))
)]
pub async fn list_artifacts_by_execution(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path(execution_id): Path<i64>,
) -> 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();
Ok((StatusCode::OK, Json(ApiResponse::new(items))))
@@ -371,7 +425,7 @@ pub async fn list_artifacts_by_execution(
security(("bearer_auth" = []))
)]
pub async fn append_progress(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
Json(request): Json<AppendProgressRequest>,
@@ -380,6 +434,8 @@ pub async fn append_progress(
.await?
.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 {
return Err(ApiError::BadRequest(format!(
"Artifact '{}' is type {:?}, not progress. Use version endpoints for file artifacts.",
@@ -414,16 +470,18 @@ pub async fn append_progress(
security(("bearer_auth" = []))
)]
pub async fn set_artifact_data(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
Json(request): Json<SetDataRequest>,
) -> ApiResult<impl IntoResponse> {
// Verify exists
ArtifactRepository::find_by_id(&state.db, id)
let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await?
.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?;
Ok((
@@ -452,15 +510,19 @@ pub async fn set_artifact_data(
security(("bearer_auth" = []))
)]
pub async fn list_versions(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
// Verify artifact exists
ArtifactRepository::find_by_id(&state.db, id)
let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await?
.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 items: Vec<ArtifactVersionSummary> = versions
.into_iter()
@@ -486,15 +548,19 @@ pub async fn list_versions(
security(("bearer_auth" = []))
)]
pub async fn get_version(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path((id, version)): Path<(i64, i32)>,
) -> ApiResult<impl IntoResponse> {
// Verify artifact exists
ArtifactRepository::find_by_id(&state.db, id)
let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await?
.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)
.await?
.ok_or_else(|| {
@@ -520,14 +586,18 @@ pub async fn get_version(
security(("bearer_auth" = []))
)]
pub async fn get_latest_version(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
ArtifactRepository::find_by_id(&state.db, id)
let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await?
.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)
.await?
.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" = []))
)]
pub async fn create_version_json(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
Json(request): Json<CreateVersionJsonRequest>,
) -> ApiResult<impl IntoResponse> {
ArtifactRepository::find_by_id(&state.db, id)
let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
let input = CreateArtifactVersionInput {
artifact: id,
content_type: Some(
@@ -608,7 +680,7 @@ pub async fn create_version_json(
security(("bearer_auth" = []))
)]
pub async fn create_version_file(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
Json(request): Json<CreateFileVersionRequest>,
@@ -617,6 +689,8 @@ pub async fn create_version_file(
.await?
.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
if !is_file_backed_type(artifact.r#type) {
return Err(ApiError::BadRequest(format!(
@@ -710,15 +784,17 @@ pub async fn create_version_file(
security(("bearer_auth" = []))
)]
pub async fn upload_version(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
mut multipart: Multipart,
) -> ApiResult<impl IntoResponse> {
ArtifactRepository::find_by_id(&state.db, id)
let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await?
.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 content_type: Option<String> = None;
let mut meta: Option<serde_json::Value> = None;
@@ -838,7 +914,7 @@ pub async fn upload_version(
security(("bearer_auth" = []))
)]
pub async fn download_version(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path((id, version)): Path<(i64, i32)>,
) -> ApiResult<impl IntoResponse> {
@@ -846,6 +922,10 @@ pub async fn download_version(
.await?
.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
let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version)
.await?
@@ -888,7 +968,7 @@ pub async fn download_version(
security(("bearer_auth" = []))
)]
pub async fn download_latest(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
@@ -896,6 +976,10 @@ pub async fn download_latest(
.await?
.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
let ver = ArtifactVersionRepository::find_latest(&state.db, id)
.await?
@@ -939,7 +1023,7 @@ pub async fn download_latest(
security(("bearer_auth" = []))
)]
pub async fn delete_version(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path((id, version)): Path<(i64, i32)>,
) -> ApiResult<impl IntoResponse> {
@@ -948,6 +1032,8 @@ pub async fn delete_version(
.await?
.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
let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version)
.await?
@@ -1026,7 +1112,7 @@ pub async fn delete_version(
security(("bearer_auth" = []))
)]
pub async fn upload_version_by_ref(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path(artifact_ref): Path<String>,
mut multipart: Multipart,
@@ -1141,6 +1227,8 @@ pub async fn upload_version_by_ref(
// Upsert: find existing artifact or create a new one
let artifact = match ArtifactRepository::find_by_ref(&state.db, &artifact_ref).await? {
Some(existing) => {
authorize_artifact_action(&state, &user, Action::Update, &existing).await?;
// Update execution link if a new execution ID was provided
if execution_id.is_some() && execution_id != existing.execution {
let update_input = UpdateArtifactInput {
@@ -1155,7 +1243,7 @@ pub async fn upload_version_by_ref(
description: None,
content_type: None,
size_bytes: None,
execution: execution_id.map(Some),
execution: execution_id.map(Patch::Set),
data: None,
};
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
let a_retention_policy: RetentionPolicyType = match &retention_policy {
Some(rp) if !rp.is_empty() => {
@@ -1281,7 +1379,7 @@ pub async fn upload_version_by_ref(
security(("bearer_auth" = []))
)]
pub async fn allocate_file_version_by_ref(
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>,
Path(artifact_ref): Path<String>,
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
let artifact = match ArtifactRepository::find_by_ref(&state.db, &artifact_ref).await? {
Some(existing) => {
authorize_artifact_action(&state, &user, Action::Update, &existing).await?;
// Update execution link if a new execution ID was provided
if request.execution.is_some() && request.execution != existing.execution {
let update_input = UpdateArtifactInput {
@@ -1303,7 +1403,7 @@ pub async fn allocate_file_version_by_ref(
description: None,
content_type: None,
size_bytes: None,
execution: request.execution.map(Some),
execution: request.execution.map(Patch::Set),
data: None,
};
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);
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 {
r#ref: artifact_ref.clone(),
scope: a_scope,
@@ -1421,6 +1531,105 @@ pub async fn allocate_file_version_by_ref(
// 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.
fn is_file_backed_type(artifact_type: ArtifactType) -> bool {
matches!(
@@ -1759,14 +1968,19 @@ pub async fn stream_artifact(
let token = params.token.as_ref().ok_or(ApiError::Unauthorized(
"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()))?;
let user = AuthenticatedUser { claims };
// --- resolve artifact + latest version ---------------------------------
let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await?
.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) {
return Err(ApiError::BadRequest(format!(
"Artifact '{}' is type {:?} which is not file-backed. \

View File

@@ -1,7 +1,9 @@
//! Authentication routes
use axum::{
extract::State,
extract::{Query, State},
http::HeaderMap,
response::{IntoResponse, Redirect, Response},
routing::{get, post},
Json, Router,
};
@@ -21,11 +23,16 @@ use crate::{
TokenType,
},
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,
},
dto::{
ApiResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest,
RegisterRequest, SuccessResponse, TokenResponse,
ApiResponse, AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse,
LoginRequest, RefreshTokenRequest, RegisterRequest, SuccessResponse, TokenResponse,
},
middleware::error::ApiError,
state::SharedState,
@@ -63,7 +70,12 @@ pub struct SensorTokenResponse {
/// Create authentication routes
pub fn routes() -> Router<SharedState> {
Router::new()
.route("/settings", get(auth_settings))
.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("/refresh", post(refresh_token))
.route("/me", get(get_current_user))
@@ -72,6 +84,63 @@ pub fn routes() -> Router<SharedState> {
.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
///
/// POST /auth/login
@@ -100,6 +169,12 @@ pub async fn login(
.await?
.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
let password_hash = identity
.password_hash
@@ -152,6 +227,12 @@ pub async fn register(
State(state): State<SharedState>,
Json(payload): Json<RegisterRequest>,
) -> 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
payload
.validate()
@@ -171,7 +252,7 @@ pub async fn register(
// Hash 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 {
login: payload.login.clone(),
display_name: payload.display_name,
@@ -215,15 +296,22 @@ pub async fn register(
)]
pub async fn refresh_token(
State(state): State<SharedState>,
Json(payload): Json<RefreshTokenRequest>,
) -> Result<Json<ApiResponse<TokenResponse>>, ApiError> {
// Validate request
payload
.validate()
.map_err(|e| ApiError::ValidationError(format!("Invalid refresh token request: {}", e)))?;
headers: HeaderMap,
payload: Option<Json<RefreshTokenRequest>>,
) -> Result<Response, ApiError> {
let browser_cookie_refresh = payload.is_none();
let refresh_token = if let Some(Json(payload)) = payload {
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
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()))?;
// Ensure it's a refresh token
@@ -242,6 +330,12 @@ pub async fn refresh_token(
.await?
.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
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)?;
@@ -251,8 +345,18 @@ pub async fn refresh_token(
refresh_token,
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
@@ -273,15 +377,27 @@ pub async fn refresh_token(
)]
pub async fn get_current_user(
State(state): State<SharedState>,
RequireAuth(user): RequireAuth,
headers: HeaderMap,
user: Result<RequireAuth, crate::auth::middleware::AuthError>,
) -> 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
let identity = IdentityRepository::find_by_id(&state.db, identity_id)
.await?
.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 {
id: identity.id,
login: identity.login,
@@ -291,6 +407,106 @@ pub async fn get_current_user(
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
///
/// POST /auth/change-password
@@ -353,6 +569,7 @@ pub async fn change_password(
display_name: None,
password_hash: Some(new_password_hash),
attributes: None,
frozen: None,
};
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>>,
Json(payload): Json<CreateEventRequest>,
) -> 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
payload
.validate()
@@ -128,7 +139,6 @@ pub async fn create_event(
};
// 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 {
TokenType::Sensor => {
// Extract sensor reference from login

View File

@@ -10,6 +10,7 @@ use axum::{
routing::get,
Json, Router,
};
use chrono::Utc;
use futures::stream::{Stream, StreamExt};
use std::sync::Arc;
use tokio_stream::wrappers::BroadcastStream;
@@ -24,13 +25,15 @@ use attune_common::repositories::{
execution::{
CreateExecutionInput, ExecutionRepository, ExecutionSearchFilters, UpdateExecutionInput,
},
workflow::WorkflowExecutionRepository,
workflow::{WorkflowDefinitionRepository, WorkflowExecutionRepository},
Create, FindById, FindByRef, Update,
};
use attune_common::workflow::{CancellationPolicy, WorkflowDefinition};
use sqlx::Row;
use crate::{
auth::middleware::RequireAuth,
authz::{AuthorizationCheck, AuthorizationService},
dto::{
common::{PaginatedResponse, PaginationParams},
execution::{
@@ -41,6 +44,7 @@ use crate::{
middleware::{ApiError, ApiResult},
state::AppState,
};
use attune_common::rbac::{Action, AuthorizationContext, Resource};
/// Create a new execution (manual execution)
///
@@ -60,7 +64,7 @@ use crate::{
)]
pub async fn create_execution(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
Json(request): Json<CreateExecutionRequest>,
) -> ApiResult<impl IntoResponse> {
// Validate that the action exists
@@ -68,6 +72,29 @@ pub async fn create_execution(
.await?
.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
let execution_input = CreateExecutionInput {
action: Some(action.id),
@@ -83,6 +110,7 @@ pub async fn create_execution(
parent: None,
enforcement: None,
executor: None,
worker: None,
status: ExecutionStatus::Requested,
result: None,
workflow_task: None, // Non-workflow execution
@@ -439,9 +467,17 @@ pub async fn cancel_execution(
..Default::default()
};
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
cancel_workflow_children(&state.db, publisher.as_deref(), id).await;
if !delegated_to_executor {
cancel_workflow_children(&state.db, publisher.as_deref(), id).await;
}
let response = ApiResponse::new(ExecutionResponse::from(updated));
return Ok((StatusCode::OK, Json(response)));
@@ -453,19 +489,27 @@ pub async fn cancel_execution(
..Default::default()
};
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
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;
} else {
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
);
}
// Cascade to workflow children if this is a workflow execution
cancel_workflow_children(&state.db, publisher.as_deref(), id).await;
if !delegated_to_executor {
cancel_workflow_children(&state.db, publisher.as_deref(), id).await;
}
let response = ApiResponse::new(ExecutionResponse::from(updated));
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.
///
/// 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
/// scheduler's `advance_workflow` will short-circuit and not dispatch new tasks.
///
/// Children in pre-running states (Requested, Scheduling, Scheduled) are set
/// to Cancelled immediately. Children that are Running receive a cancel MQ
/// message so their worker can gracefully stop the process.
/// Behaviour depends on the workflow's [`CancellationPolicy`]:
///
/// - **`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(
db: &sqlx::PgPool,
publisher: Option<&Publisher>,
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
let children: Vec<attune_common::models::Execution> = match sqlx::query_as::<
@@ -546,9 +695,10 @@ async fn cancel_workflow_children(
}
tracing::info!(
"Cascading cancellation from execution {} to {} child execution(s)",
"Cascading cancellation from execution {} to {} child execution(s) (policy: {:?})",
parent_execution_id,
children.len()
children.len(),
policy,
);
for child in &children {
@@ -558,7 +708,7 @@ async fn cancel_workflow_children(
child.status,
ExecutionStatus::Requested | ExecutionStatus::Scheduling | ExecutionStatus::Scheduled
) {
// Pre-running: cancel immediately in DB
// Pre-running: cancel immediately in DB (both policies)
let update = UpdateExecutionInput {
status: Some(ExecutionStatus::Cancelled),
result: Some(serde_json::json!({
@@ -575,29 +725,45 @@ async fn cancel_workflow_children(
child.status,
ExecutionStatus::Running | ExecutionStatus::Canceling
) {
// Running: set to Canceling and send MQ message to the worker
if child.status != ExecutionStatus::Canceling {
let update = UpdateExecutionInput {
status: Some(ExecutionStatus::Canceling),
..Default::default()
};
if let Err(e) = ExecutionRepository::update(db, child_id, update).await {
tracing::error!(
"Failed to set child execution {} to canceling: {}",
child_id,
e
match policy {
CancellationPolicy::CancelRunning => {
// Running: set to Canceling and send MQ message to the worker
if child.status != ExecutionStatus::Canceling {
let update = UpdateExecutionInput {
status: Some(ExecutionStatus::Canceling),
..Default::default()
};
if let Err(e) = ExecutionRepository::update(db, child_id, update).await {
tracing::error!(
"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)
// 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
@@ -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

View File

@@ -10,7 +10,6 @@ use axum::{
use std::sync::Arc;
use validator::Validate;
use attune_common::models::OwnerType;
use attune_common::repositories::{
action::ActionRepository,
key::{CreateKeyInput, KeyRepository, KeySearchFilters, UpdateKeyInput},
@@ -18,9 +17,14 @@ use attune_common::repositories::{
trigger::SensorRepository,
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::{
authz::{AuthorizationCheck, AuthorizationService},
dto::{
common::{PaginatedResponse, PaginationParams},
key::{CreateKeyRequest, KeyQueryParams, KeyResponse, KeySummary, UpdateKeyRequest},
@@ -42,7 +46,7 @@ use crate::{
security(("bearer_auth" = []))
)]
pub async fn list_keys(
_user: RequireAuth,
user: RequireAuth,
State(state): State<Arc<AppState>>,
Query(query): Query<KeyQueryParams>,
) -> ApiResult<impl IntoResponse> {
@@ -55,8 +59,33 @@ pub async fn list_keys(
};
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 {
page: query.page,
@@ -83,7 +112,7 @@ pub async fn list_keys(
security(("bearer_auth" = []))
)]
pub async fn get_key(
_user: RequireAuth,
user: RequireAuth,
State(state): State<Arc<AppState>>,
Path(key_ref): Path<String>,
) -> ApiResult<impl IntoResponse> {
@@ -91,24 +120,75 @@ pub async fn get_key(
.await?
.ok_or_else(|| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?;
// Decrypt value if encrypted
if key.encrypted {
let encryption_key = state
.config
.security
.encryption_key
.as_ref()
.ok_or_else(|| {
ApiError::InternalServerError("Encryption key not configured on server".to_string())
})?;
// For encrypted keys, track whether this caller is permitted to see the value.
// Non-Access tokens (sensor, execution) always get full access.
let can_decrypt = 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 decrypted_value =
attune_common::crypto::decrypt(&key.value, encryption_key).map_err(|e| {
// Basic read check — hide behind 404 to prevent enumeration.
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);
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));
@@ -130,13 +210,38 @@ pub async fn get_key(
security(("bearer_auth" = []))
)]
pub async fn create_key(
_user: RequireAuth,
user: RequireAuth,
State(state): State<Arc<AppState>>,
Json(request): Json<CreateKeyRequest>,
) -> ApiResult<impl IntoResponse> {
// Validate request
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
if KeyRepository::find_by_ref(&state.db, &request.r#ref)
.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| {
tracing::error!("Failed to encrypt key value: {}", e);
ApiError::InternalServerError(format!("Failed to encrypt value: {}", e))
})?;
tracing::error!("Failed to encrypt key value: {}", e);
ApiError::InternalServerError(format!("Failed to encrypt value: {}", e))
})?;
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
if key.encrypted {
let encryption_key = state.config.security.encryption_key.as_ref().unwrap();
key.value = attune_common::crypto::decrypt(&key.value, encryption_key).map_err(|e| {
tracing::error!("Failed to decrypt newly created key: {}", e);
ApiError::InternalServerError(format!("Failed to decrypt value: {}", e))
})?;
key.value =
attune_common::crypto::decrypt_json(&key.value, encryption_key).map_err(|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");
@@ -298,7 +404,7 @@ pub async fn create_key(
security(("bearer_auth" = []))
)]
pub async fn update_key(
_user: RequireAuth,
user: RequireAuth,
State(state): State<Arc<AppState>>,
Path(key_ref): Path<String>,
Json(request): Json<UpdateKeyRequest>,
@@ -311,6 +417,24 @@ pub async fn update_key(
.await?
.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
let (value, encrypted, encryption_key_hash) = if let Some(new_value) = request.value {
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| {
tracing::error!("Failed to encrypt key value: {}", e);
ApiError::InternalServerError(format!("Failed to encrypt value: {}", e))
})?;
tracing::error!("Failed to encrypt key value: {}", e);
ApiError::InternalServerError(format!("Failed to encrypt value: {}", e))
})?;
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())
})?;
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| {
tracing::error!("Failed to decrypt updated key '{}': {}", key_ref, e);
ApiError::InternalServerError(format!("Failed to decrypt value: {}", e))
@@ -394,7 +518,7 @@ pub async fn update_key(
security(("bearer_auth" = []))
)]
pub async fn delete_key(
_user: RequireAuth,
user: RequireAuth,
State(state): State<Arc<AppState>>,
Path(key_ref): Path<String>,
) -> ApiResult<impl IntoResponse> {
@@ -403,6 +527,24 @@ pub async fn delete_key(
.await?
.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
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),
)
}
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
pub mod actions;
pub mod agent;
pub mod analytics;
pub mod artifacts;
pub mod auth;
@@ -11,12 +12,15 @@ pub mod history;
pub mod inquiries;
pub mod keys;
pub mod packs;
pub mod permissions;
pub mod rules;
pub mod runtimes;
pub mod triggers;
pub mod webhooks;
pub mod workflows;
pub use actions::routes as action_routes;
pub use agent::routes as agent_routes;
pub use analytics::routes as analytics_routes;
pub use artifacts::routes as artifact_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 keys::routes as key_routes;
pub use packs::routes as pack_routes;
pub use permissions::routes as permission_routes;
pub use rules::routes as rule_routes;
pub use runtimes::routes as runtime_routes;
pub use triggers::routes as trigger_routes;
pub use webhooks::routes as webhook_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::mq::{MessageEnvelope, MessageType, PackRegisteredPayload};
use attune_common::rbac::{Action, AuthorizationContext, Resource};
use attune_common::repositories::{
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 crate::{
auth::middleware::RequireAuth,
authz::{AuthorizationCheck, AuthorizationService},
dto::{
common::{PaginatedResponse, PaginationParams},
pack::{
BuildPackEnvsRequest, BuildPackEnvsResponse, CreatePackRequest, DownloadPacksRequest,
DownloadPacksResponse, GetPackDependenciesRequest, GetPackDependenciesResponse,
InstallPackRequest, PackInstallResponse, PackResponse, PackSummary,
PackWorkflowSyncResponse, PackWorkflowValidationResponse, RegisterPackRequest,
RegisterPacksRequest, RegisterPacksResponse, UpdatePackRequest, WorkflowSyncResult,
InstallPackRequest, PackDescriptionPatch, PackInstallResponse, PackResponse,
PackSummary, PackWorkflowSyncResponse, PackWorkflowValidationResponse,
RegisterPackRequest, RegisterPacksRequest, RegisterPacksResponse, UpdatePackRequest,
WorkflowSyncResult,
},
ApiResponse, SuccessResponse,
},
@@ -115,7 +119,7 @@ pub async fn get_pack(
)]
pub async fn create_pack(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
Json(request): Json<CreatePackRequest>,
) -> ApiResult<impl IntoResponse> {
// 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
let pack_input = CreatePackInput {
r#ref: request.r#ref,
@@ -202,7 +225,7 @@ pub async fn create_pack(
)]
pub async fn update_pack(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
Path(pack_ref): Path<String>,
Json(request): Json<UpdatePackRequest>,
) -> ApiResult<impl IntoResponse> {
@@ -214,10 +237,33 @@ pub async fn update_pack(
.await?
.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
let update_input = UpdatePackInput {
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,
conf_schema: request.conf_schema,
config: request.config,
@@ -284,7 +330,7 @@ pub async fn update_pack(
)]
pub async fn delete_pack(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
Path(pack_ref): Path<String>,
) -> ApiResult<impl IntoResponse> {
// Check if pack exists
@@ -292,6 +338,26 @@ pub async fn delete_pack(
.await?
.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.
// 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.)
@@ -475,6 +541,23 @@ pub async fn upload_pack(
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 force = false;
let mut skip_tests = false;
@@ -649,6 +732,23 @@ pub async fn register_pack(
// Validate request
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
let pack_id = register_pack_internal(
state.clone(),
@@ -781,7 +881,10 @@ async fn register_pack_internal(
// Update existing pack in place — preserves pack ID and all child entity IDs
let update_input = UpdatePackInput {
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()),
conf_schema: Some(conf_schema),
config: None, // preserve user-set config
@@ -1207,6 +1310,23 @@ pub async fn install_pack(
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
let user_id = user.identity_id().ok();
let user_sub = user.claims.sub.clone();
@@ -2247,6 +2367,23 @@ pub async fn register_packs_batch(
RequireAuth(user): RequireAuth,
Json(request): Json<RegisterPacksRequest>,
) -> 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 mut registered = 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::{
MessageEnvelope, MessageType, RuleCreatedPayload, RuleDisabledPayload, RuleEnabledPayload,
};
use attune_common::rbac::{Action, AuthorizationContext, Resource};
use attune_common::repositories::{
action::ActionRepository,
pack::PackRepository,
rule::{CreateRuleInput, RuleRepository, RuleSearchFilters, UpdateRuleInput},
trigger::TriggerRepository,
Create, Delete, FindByRef, Update,
Create, Delete, FindByRef, Patch, Update,
};
use crate::{
auth::middleware::RequireAuth,
authz::{AuthorizationCheck, AuthorizationService},
dto::{
common::{PaginatedResponse, PaginationParams},
rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest},
@@ -283,7 +285,7 @@ pub async fn get_rule(
)]
pub async fn create_rule(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
Json(request): Json<CreateRuleRequest>,
) -> ApiResult<impl IntoResponse> {
// Validate request
@@ -317,6 +319,26 @@ pub async fn create_rule(
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_params(&trigger, &request.trigger_params)?;
@@ -392,7 +414,7 @@ pub async fn create_rule(
)]
pub async fn update_rule(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
Path(rule_ref): Path<String>,
Json(request): Json<UpdateRuleRequest>,
) -> ApiResult<impl IntoResponse> {
@@ -404,6 +426,27 @@ pub async fn update_rule(
.await?
.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 let Some(ref action_params) = request.action_params {
let action = ActionRepository::find_by_ref(&state.db, &existing_rule.action_ref)
@@ -431,7 +474,7 @@ pub async fn update_rule(
// Create update input
let update_input = UpdateRuleInput {
label: request.label,
description: request.description,
description: request.description.map(Patch::Set),
conditions: request.conditions,
action_params: request.action_params,
trigger_params: request.trigger_params,
@@ -489,7 +532,7 @@ pub async fn update_rule(
)]
pub async fn delete_rule(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
Path(rule_ref): Path<String>,
) -> ApiResult<impl IntoResponse> {
// Check if rule exists
@@ -497,6 +540,27 @@ pub async fn delete_rule(
.await?
.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
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,
TriggerRepository, TriggerSearchFilters, UpdateSensorInput, UpdateTriggerInput,
},
Create, Delete, FindByRef, Update,
Create, Delete, FindByRef, Patch, Update,
};
use crate::{
@@ -25,8 +25,9 @@ use crate::{
dto::{
common::{PaginatedResponse, PaginationParams},
trigger::{
CreateSensorRequest, CreateTriggerRequest, SensorResponse, SensorSummary,
TriggerResponse, TriggerSummary, UpdateSensorRequest, UpdateTriggerRequest,
CreateSensorRequest, CreateTriggerRequest, SensorJsonPatch, SensorResponse,
SensorSummary, TriggerJsonPatch, TriggerResponse, TriggerStringPatch, TriggerSummary,
UpdateSensorRequest, UpdateTriggerRequest,
},
ApiResponse, SuccessResponse,
},
@@ -274,10 +275,19 @@ pub async fn update_trigger(
// Create update input
let update_input = UpdateTriggerInput {
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,
param_schema: request.param_schema,
out_schema: request.out_schema,
param_schema: request.param_schema.map(|patch| match patch {
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?;
@@ -714,7 +724,7 @@ pub async fn update_sensor(
// Create update input
let update_input = UpdateSensorInput {
label: request.label,
description: request.description,
description: request.description.map(Patch::Set),
entrypoint: request.entrypoint,
runtime: None,
runtime_ref: None,
@@ -722,7 +732,10 @@ pub async fn update_sensor(
trigger: None,
trigger_ref: None,
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,
};

View File

@@ -20,8 +20,11 @@ use attune_common::{
},
};
use attune_common::rbac::{Action, AuthorizationContext, Resource};
use crate::{
auth::middleware::RequireAuth,
authz::{AuthorizationCheck, AuthorizationService},
dto::{
trigger::TriggerResponse,
webhook::{WebhookReceiverRequest, WebhookReceiverResponse},
@@ -170,7 +173,7 @@ fn get_webhook_config_array(
)]
pub async fn enable_webhook(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
Path(trigger_ref): Path<String>,
) -> ApiResult<impl IntoResponse> {
// 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()))?
.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
let _webhook_info = TriggerRepository::enable_webhook(&state.db, trigger.id)
.await
@@ -213,7 +236,7 @@ pub async fn enable_webhook(
)]
pub async fn disable_webhook(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
Path(trigger_ref): Path<String>,
) -> ApiResult<impl IntoResponse> {
// 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()))?
.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
TriggerRepository::disable_webhook(&state.db, trigger.id)
.await
@@ -257,7 +300,7 @@ pub async fn disable_webhook(
)]
pub async fn regenerate_webhook_key(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
Path(trigger_ref): Path<String>,
) -> ApiResult<impl IntoResponse> {
// 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()))?
.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
if !trigger.webhook_enabled {
return Err(ApiError::BadRequest(

View File

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

View File

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

View File

@@ -362,7 +362,7 @@ mod tests {
pack: 1,
pack_ref: "test".to_string(),
label: "Test Action".to_string(),
description: "Test action".to_string(),
description: Some("Test action".to_string()),
entrypoint: "test.sh".to_string(),
runtime: Some(1),
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;
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_register_debug() {
let ctx = TestContext::new()
.await
@@ -36,6 +37,7 @@ async fn test_register_debug() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_health_check() {
let ctx = TestContext::new()
.await
@@ -54,6 +56,7 @@ async fn test_health_check() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_health_detailed() {
let ctx = TestContext::new()
.await
@@ -74,6 +77,7 @@ async fn test_health_detailed() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_health_ready() {
let ctx = TestContext::new()
.await
@@ -90,6 +94,7 @@ async fn test_health_ready() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_health_live() {
let ctx = TestContext::new()
.await
@@ -106,6 +111,7 @@ async fn test_health_live() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_register_user() {
let ctx = TestContext::new()
.await
@@ -137,6 +143,7 @@ async fn test_register_user() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_register_duplicate_user() {
let ctx = TestContext::new()
.await
@@ -174,6 +181,7 @@ async fn test_register_duplicate_user() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_register_invalid_password() {
let ctx = TestContext::new()
.await
@@ -196,6 +204,7 @@ async fn test_register_invalid_password() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_login_success() {
let ctx = TestContext::new()
.await
@@ -238,6 +247,7 @@ async fn test_login_success() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_login_wrong_password() {
let ctx = TestContext::new()
.await
@@ -274,6 +284,7 @@ async fn test_login_wrong_password() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_login_nonexistent_user() {
let ctx = TestContext::new()
.await
@@ -294,7 +305,128 @@ async fn test_login_nonexistent_user() {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
// ── LDAP auth tests ──────────────────────────────────────────────────
#[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() {
let ctx = TestContext::new()
.await
@@ -318,6 +450,7 @@ async fn test_get_current_user() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_get_current_user_unauthorized() {
let ctx = TestContext::new()
.await
@@ -332,6 +465,7 @@ async fn test_get_current_user_unauthorized() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_get_current_user_invalid_token() {
let ctx = TestContext::new()
.await
@@ -346,6 +480,7 @@ async fn test_get_current_user_invalid_token() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_refresh_token() {
let ctx = TestContext::new()
.await
@@ -396,6 +531,7 @@ async fn test_refresh_token() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_refresh_with_invalid_token() {
let ctx = TestContext::new()
.await

View File

@@ -9,6 +9,10 @@ use attune_common::{
models::*,
repositories::{
action::{ActionRepository, CreateActionInput},
identity::{
CreatePermissionAssignmentInput, CreatePermissionSetInput,
PermissionAssignmentRepository, PermissionSetRepository,
},
pack::{CreatePackInput, PackRepository},
trigger::{CreateTriggerInput, TriggerRepository},
workflow::{CreateWorkflowDefinitionInput, WorkflowDefinitionRepository},
@@ -237,6 +241,7 @@ impl TestContext {
}
/// Create and authenticate a test user
#[allow(dead_code)]
pub async fn with_auth(mut self) -> Result<Self> {
// Generate unique username to avoid conflicts in parallel tests
let unique_id = uuid::Uuid::new_v4().to_string().replace("-", "")[..8].to_string();
@@ -246,6 +251,48 @@ impl TestContext {
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
async fn create_test_user(&self, login: &str) -> Result<String> {
// Register via API to get real token
@@ -348,6 +395,7 @@ impl TestContext {
}
/// Get authenticated token
#[allow(dead_code)]
pub fn token(&self) -> Option<&str> {
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_ref: format!("pack_{}", pack_id),
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(),
runtime: None,
runtime_version_constraint: None,
@@ -506,7 +554,6 @@ pub async fn create_test_workflow(
]
}),
tags: vec!["test".to_string()],
enabled: true,
};
Ok(WorkflowDefinitionRepository::create(pool, input).await?)

View File

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

View File

@@ -22,7 +22,6 @@ ref: {}.example_workflow
label: Example Workflow
description: A test workflow for integration testing
version: "1.0.0"
enabled: true
parameters:
message:
type: string
@@ -46,7 +45,6 @@ ref: {}.another_workflow
label: Another Workflow
description: Second test workflow
version: "1.0.0"
enabled: false
tasks:
- name: task1
action: core.noop
@@ -58,6 +56,7 @@ tasks:
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_sync_pack_workflows_endpoint() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -94,6 +93,7 @@ async fn test_sync_pack_workflows_endpoint() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_validate_pack_workflows_endpoint() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -120,6 +120,7 @@ async fn test_validate_pack_workflows_endpoint() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_sync_nonexistent_pack_returns_404() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -136,6 +137,7 @@ async fn test_sync_nonexistent_pack_returns_404() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_validate_nonexistent_pack_returns_404() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -152,6 +154,7 @@ async fn test_validate_nonexistent_pack_returns_404() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_sync_workflows_requires_authentication() {
let ctx = TestContext::new().await.unwrap();
@@ -179,6 +182,7 @@ async fn test_sync_workflows_requires_authentication() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_validate_workflows_requires_authentication() {
let ctx = TestContext::new().await.unwrap();
@@ -206,6 +210,7 @@ async fn test_validate_workflows_requires_authentication() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_pack_creation_with_auto_sync() {
let ctx = TestContext::new().await.unwrap().with_auth().await.unwrap();
@@ -236,6 +241,7 @@ async fn test_pack_creation_with_auto_sync() {
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_pack_update_with_auto_resync() {
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_ref: pack.r#ref.clone(),
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(),
runtime: None,
runtime_version_constraint: None,
@@ -75,6 +75,7 @@ async fn create_test_execution(pool: &PgPool, action_id: i64) -> Result<Executio
parent: None,
enforcement: None,
executor: None,
worker: None,
status: ExecutionStatus::Scheduled,
result: 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
/// After starting: cargo run -p attune-api -- -c config.test.yaml
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_sse_stream_receives_execution_updates() -> Result<()> {
// Set up test context with auth
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
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_sse_stream_filters_by_execution_id() -> Result<()> {
// Set up test context with auth
let ctx = TestContext::new().await?.with_auth().await?;
@@ -327,7 +328,7 @@ async fn test_sse_stream_filters_by_execution_id() -> Result<()> {
}
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_sse_stream_requires_authentication() -> Result<()> {
// Try to connect without token
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)
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_sse_stream_all_executions() -> Result<()> {
// Set up test context with auth
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
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_postgresql_notify_trigger_fires() -> Result<()> {
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]
#[ignore] // Run with --ignored flag when database is available
#[ignore = "integration test — requires database"]
async fn test_enable_webhook() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -151,7 +151,7 @@ async fn test_enable_webhook() {
}
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_disable_webhook() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -202,7 +202,7 @@ async fn test_disable_webhook() {
}
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_regenerate_webhook_key() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -254,7 +254,7 @@ async fn test_regenerate_webhook_key() {
}
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_regenerate_webhook_key_not_enabled() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -291,7 +291,7 @@ async fn test_regenerate_webhook_key_not_enabled() {
}
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_receive_webhook() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -362,7 +362,7 @@ async fn test_receive_webhook() {
}
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_receive_webhook_invalid_key() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state));
@@ -392,7 +392,7 @@ async fn test_receive_webhook_invalid_key() {
}
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_receive_webhook_disabled() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -442,7 +442,7 @@ async fn test_receive_webhook_disabled() {
}
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_webhook_requires_auth_for_management() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -475,7 +475,7 @@ async fn test_webhook_requires_auth_for_management() {
}
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_receive_webhook_minimal_payload() {
let state = setup_test_state().await;
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]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_webhook_hmac_sha256_valid() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -189,7 +189,7 @@ async fn test_webhook_hmac_sha256_valid() {
}
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_webhook_hmac_sha512_valid() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -246,7 +246,7 @@ async fn test_webhook_hmac_sha512_valid() {
}
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_webhook_hmac_invalid_signature() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -302,7 +302,7 @@ async fn test_webhook_hmac_invalid_signature() {
}
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_webhook_hmac_missing_signature() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -355,7 +355,7 @@ async fn test_webhook_hmac_missing_signature() {
}
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_webhook_hmac_wrong_secret() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -418,7 +418,7 @@ async fn test_webhook_hmac_wrong_secret() {
// ============================================================================
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_webhook_rate_limit_enforced() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -494,7 +494,7 @@ async fn test_webhook_rate_limit_enforced() {
}
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_webhook_rate_limit_disabled() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -541,7 +541,7 @@ async fn test_webhook_rate_limit_disabled() {
// ============================================================================
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_webhook_ip_whitelist_allowed() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -612,7 +612,7 @@ async fn test_webhook_ip_whitelist_allowed() {
}
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_webhook_ip_whitelist_blocked() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -669,7 +669,7 @@ async fn test_webhook_ip_whitelist_blocked() {
// ============================================================================
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_webhook_payload_size_limit_enforced() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));
@@ -720,7 +720,7 @@ async fn test_webhook_payload_size_limit_enforced() {
}
#[tokio::test]
#[ignore]
#[ignore = "integration test — requires database"]
async fn test_webhook_payload_size_within_limit() {
let state = setup_test_state().await;
let server = Server::new(std::sync::Arc::new(state.clone()));

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
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 std::path::PathBuf;
use std::time::Duration;
@@ -347,6 +347,80 @@ impl ApiClient {
.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.
///
/// - `file_field_name`: the multipart field name for the file

View File

@@ -90,7 +90,7 @@ struct Action {
action_ref: String,
pack_ref: String,
label: String,
description: String,
description: Option<String>,
entrypoint: String,
runtime: Option<i64>,
created: String,
@@ -105,7 +105,7 @@ struct ActionDetail {
pack: i64,
pack_ref: String,
label: String,
description: String,
description: Option<String>,
entrypoint: String,
runtime: Option<i64>,
param_schema: Option<serde_json::Value>,
@@ -241,7 +241,7 @@ async fn handle_list(
let mut table = output::create_table();
output::add_header(
&mut table,
vec!["ID", "Pack", "Name", "Runner", "Enabled", "Description"],
vec!["ID", "Pack", "Name", "Runner", "Description"],
);
for action in actions {
@@ -253,8 +253,7 @@ async fn handle_list(
.runtime
.map(|r| r.to_string())
.unwrap_or_else(|| "none".to_string()),
"".to_string(),
output::truncate(&action.description, 40),
output::truncate(&action.description.unwrap_or_default(), 40),
]);
}
@@ -289,7 +288,10 @@ async fn handle_show(
("Reference", action.action_ref.clone()),
("Pack", action.pack_ref.clone()),
("Label", action.label.clone()),
("Description", action.description.clone()),
(
"Description",
action.description.unwrap_or_else(|| "None".to_string()),
),
("Entry Point", action.entrypoint.clone()),
(
"Runtime",
@@ -357,7 +359,10 @@ async fn handle_update(
("Ref", action.action_ref.clone()),
("Pack", action.pack_ref.clone()),
("Label", action.label.clone()),
("Description", action.description.clone()),
(
"Description",
action.description.unwrap_or_else(|| "None".to_string()),
),
("Entrypoint", action.entrypoint.clone()),
(
"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 {
OutputFormat::Json | OutputFormat::Yaml => {
let result = serde_json::json!({
"current_profile": config.current_profile
"profile": config.current_profile
});
output::print_output(&result, output_format)?;
}
@@ -194,7 +194,7 @@ async fn handle_use(name: String, output_format: OutputFormat) -> Result<()> {
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
let result = serde_json::json!({
"current_profile": name,
"profile": name,
"message": "Switched profile"
});
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 {
pairs.push(("Description", description.clone()));
}

View File

@@ -50,7 +50,7 @@ pub enum ExecutionCommands {
execution_id: i64,
/// Skip confirmation prompt
#[arg(short = 'y', long)]
#[arg(long)]
yes: bool,
},
/// 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 artifact;
pub mod auth;
pub mod config;
pub mod execution;
pub mod key;
pub mod pack;
pub mod pack_index;
pub mod rule;

View File

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

View File

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

View File

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

View File

@@ -5,25 +5,35 @@ use std::env;
use std::fs;
use std::path::PathBuf;
use crate::output::OutputFormat;
/// CLI configuration stored in user's home directory
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliConfig {
/// Current active profile name
#[serde(default = "default_profile_name")]
#[serde(
default = "default_profile_name",
rename = "profile",
alias = "current_profile"
)]
pub current_profile: String,
/// Named profiles (like SSH hosts)
#[serde(default)]
pub profiles: HashMap<String, Profile>,
/// Default output format (can be overridden per-profile)
#[serde(default = "default_output_format")]
pub default_output_format: String,
/// Output format (table, json, yaml)
#[serde(
default = "default_format",
rename = "format",
alias = "default_output_format"
)]
pub format: String,
}
fn default_profile_name() -> String {
"default".to_string()
}
fn default_output_format() -> String {
fn default_format() -> String {
"table".to_string()
}
@@ -38,8 +48,9 @@ pub struct Profile {
/// Refresh token
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
/// Output format override for this profile
#[serde(skip_serializing_if = "Option::is_none")]
/// Output format override for this profile (deprecated — ignored, kept for deserialization compat)
#[serde(skip_serializing)]
#[allow(dead_code)]
pub output_format: Option<String>,
/// Optional description
#[serde(skip_serializing_if = "Option::is_none")]
@@ -63,7 +74,7 @@ impl Default for CliConfig {
Self {
current_profile: "default".to_string(),
profiles,
default_output_format: default_output_format(),
format: default_format(),
}
}
}
@@ -193,6 +204,29 @@ impl CliConfig {
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
pub fn set_value(&mut self, key: &str, value: String) -> Result<()> {
match key {
@@ -200,14 +234,18 @@ impl CliConfig {
let profile = self.current_profile_mut()?;
profile.api_url = value;
}
"output_format" => {
let profile = self.current_profile_mut()?;
profile.output_format = Some(value);
"format" | "output_format" | "default_output_format" => {
// Validate the 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" => {
self.default_output_format = value;
}
"current_profile" => {
"profile" | "current_profile" => {
self.switch_profile(value)?;
return Ok(());
}
@@ -223,15 +261,8 @@ impl CliConfig {
let profile = self.current_profile()?;
Ok(profile.api_url.clone())
}
"output_format" => {
let profile = self.current_profile()?;
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()),
"format" | "output_format" | "default_output_format" => Ok(self.format.clone()),
"profile" | "current_profile" => Ok(self.current_profile.clone()),
"auth_token" => {
let profile = self.current_profile()?;
Ok(profile
@@ -262,19 +293,9 @@ impl CliConfig {
};
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()),
(
"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(),
profile
@@ -354,7 +375,7 @@ mod tests {
fn test_default_config() {
let config = CliConfig::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"));
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]
fn test_profile_management() {
let mut config = CliConfig::default();
@@ -387,7 +439,7 @@ mod tests {
api_url: "https://staging.example.com".to_string(),
auth_token: None,
refresh_token: None,
output_format: Some("json".to_string()),
output_format: None,
description: Some("Staging environment".to_string()),
};
config
@@ -442,7 +494,7 @@ mod tests {
config.get_value("api_url").unwrap(),
"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
config
@@ -450,10 +502,53 @@ mod tests {
.unwrap();
assert_eq!(config.get_value("api_url").unwrap(), "http://test.com");
// Set output format for current profile
config
// Set format
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())
.unwrap();
.is_ok());
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::{
action::{handle_action_command, ActionCommands},
artifact::ArtifactCommands,
auth::AuthCommands,
config::ConfigCommands,
execution::ExecutionCommands,
key::KeyCommands,
pack::PackCommands,
rule::RuleCommands,
sensor::SensorCommands,
@@ -33,8 +35,8 @@ struct Cli {
api_url: Option<String>,
/// Output format
#[arg(long, value_enum, default_value = "table", global = true, conflicts_with_all = ["json", "yaml"])]
output: output::OutputFormat,
#[arg(long, value_enum, global = true, conflicts_with_all = ["json", "yaml"])]
output: Option<output::OutputFormat>,
/// Output as JSON (shorthand for --output json)
#[arg(short = 'j', long, global = true, conflicts_with_all = ["output", "yaml"])]
@@ -74,6 +76,11 @@ enum Commands {
#[command(subcommand)]
command: RuleCommands,
},
/// Key/secret management
Key {
#[command(subcommand)]
command: KeyCommands,
},
/// Execution monitoring
Execution {
#[command(subcommand)]
@@ -94,6 +101,11 @@ enum Commands {
#[command(subcommand)]
command: SensorCommands,
},
/// Artifact management (list, upload, download, delete)
Artifact {
#[command(subcommand)]
command: ArtifactCommands,
},
/// Configuration management
Config {
#[command(subcommand)]
@@ -129,6 +141,9 @@ enum Commands {
#[tokio::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();
// Initialize logging
@@ -138,14 +153,17 @@ async fn main() {
.init();
}
// Determine output format from flags
let output_format = if cli.json {
output::OutputFormat::Json
// Determine output format: explicit CLI flags > config file > default (table)
let cli_override = if cli.json {
Some(output::OutputFormat::Json)
} else if cli.yaml {
output::OutputFormat::Yaml
Some(output::OutputFormat::Yaml)
} else {
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 {
Commands::Auth { command } => {
@@ -169,6 +187,10 @@ async fn main() {
commands::rule::handle_rule_command(&cli.profile, command, &cli.api_url, output_format)
.await
}
Commands::Key { command } => {
commands::key::handle_key_command(&cli.profile, command, &cli.api_url, output_format)
.await
}
Commands::Execution { command } => {
commands::execution::handle_execution_command(
&cli.profile,
@@ -205,6 +227,15 @@ async fn main() {
)
.await
}
Commands::Artifact { command } => {
commands::artifact::handle_artifact_command(
&cli.profile,
command,
&cli.api_url,
output_format,
)
.await
}
Commands::Config { command } => {
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
}
/// 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]
fn test_pack_checksum_directory() {
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")
.arg("table")
.arg("pack")
@@ -135,7 +157,7 @@ fn test_pack_checksum_directory() {
fn test_pack_checksum_json_output() {
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")
.arg("json")
.arg("pack")
@@ -153,7 +175,7 @@ fn test_pack_checksum_json_output() {
#[test]
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.assert().failure().stderr(
@@ -165,7 +187,7 @@ fn test_pack_checksum_nonexistent_path() {
fn test_pack_index_entry_generates_valid_json() {
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")
.arg("json")
.arg("pack")
@@ -199,7 +221,7 @@ fn test_pack_index_entry_generates_valid_json() {
fn test_pack_index_entry_with_archive_url() {
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")
.arg("json")
.arg("pack")
@@ -227,7 +249,7 @@ fn test_pack_index_entry_missing_pack_yaml() {
let temp_dir = TempDir::new().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")
.arg("index-entry")
.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 mut cmd = Command::cargo_bin("attune").unwrap();
let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("pack")
.arg("index-update")
.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 mut cmd = Command::cargo_bin("attune").unwrap();
let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("pack")
.arg("index-update")
.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 mut cmd = Command::cargo_bin("attune").unwrap();
let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("pack")
.arg("index-update")
.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 mut cmd = Command::cargo_bin("attune").unwrap();
let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("pack")
.arg("index-update")
.arg("--index")
@@ -345,8 +367,10 @@ fn test_pack_index_merge_combines_indexes() {
let output_dir = TempDir::new().unwrap();
let output_path = output_dir.path().join("merged.json");
let mut cmd = Command::cargo_bin("attune").unwrap();
cmd.arg("pack")
let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("--output")
.arg("table")
.arg("pack")
.arg("index-merge")
.arg("--file")
.arg(output_path.to_str().unwrap())
@@ -372,8 +396,10 @@ fn test_pack_index_merge_deduplicates() {
let output_dir = TempDir::new().unwrap();
let output_path = output_dir.path().join("merged.json");
let mut cmd = Command::cargo_bin("attune").unwrap();
cmd.arg("pack")
let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("--output")
.arg("table")
.arg("pack")
.arg("index-merge")
.arg("--file")
.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");
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")
.arg("index-merge")
.arg("--file")
@@ -423,7 +449,7 @@ fn test_pack_index_merge_with_force_flag() {
let output_path = output_dir.path().join("merged.json");
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")
.arg("index-merge")
.arg("--file")
@@ -443,7 +469,7 @@ fn test_pack_index_merge_empty_input_list() {
let output_dir = TempDir::new().unwrap();
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")
.arg("index-merge")
.arg("--file")
@@ -459,8 +485,10 @@ fn test_pack_index_merge_missing_input_file() {
let output_dir = TempDir::new().unwrap();
let output_path = output_dir.path().join("merged.json");
let mut cmd = Command::cargo_bin("attune").unwrap();
cmd.arg("pack")
let (mut cmd, _config_dir) = isolated_cmd();
cmd.arg("--output")
.arg("table")
.arg("pack")
.arg("index-merge")
.arg("--file")
.arg(output_path.to_str().unwrap())
@@ -483,7 +511,7 @@ fn test_pack_commands_help() {
];
for args in commands {
let mut cmd = Command::cargo_bin("attune").unwrap();
let (mut cmd, _config_dir) = isolated_cmd();
for arg in &args {
cmd.arg(arg);
}

View File

@@ -20,7 +20,7 @@ async fn test_config_show_default() {
cmd.assert()
.success()
.stdout(predicate::str::contains("current_profile"))
.stdout(predicate::str::contains("profile"))
.stdout(predicate::str::contains("api_url"));
}
@@ -38,7 +38,7 @@ async fn test_config_show_json_output() {
cmd.assert()
.success()
.stdout(predicate::str::contains(r#""current_profile""#))
.stdout(predicate::str::contains(r#""profile""#))
.stdout(predicate::str::contains(r#""api_url""#));
}
@@ -56,7 +56,7 @@ async fn test_config_show_yaml_output() {
cmd.assert()
.success()
.stdout(predicate::str::contains("current_profile:"))
.stdout(predicate::str::contains("profile:"))
.stdout(predicate::str::contains("api_url:"));
}
@@ -118,7 +118,7 @@ async fn test_config_set_api_url() {
}
#[tokio::test]
async fn test_config_set_output_format() {
async fn test_config_set_format() {
let fixture = TestFixture::new().await;
fixture.write_default_config();
@@ -127,7 +127,7 @@ async fn test_config_set_output_format() {
.env("HOME", fixture.config_dir_path())
.arg("config")
.arg("set")
.arg("output_format")
.arg("format")
.arg("json");
cmd.assert()
@@ -137,7 +137,7 @@ async fn test_config_set_output_format() {
// Verify the change was persisted
let config_content =
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]
@@ -273,7 +273,7 @@ async fn test_profile_use_switch() {
// Verify the current profile was changed
let config_content =
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]
@@ -384,7 +384,7 @@ async fn test_profile_override_with_flag() {
// Verify current profile wasn't changed in the config file
let config_content =
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]
@@ -405,28 +405,35 @@ async fn test_profile_override_with_env_var() {
// Verify current profile wasn't changed in the config file
let config_content =
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]
async fn test_profile_with_custom_output_format() {
async fn test_config_format_respected_by_commands() {
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();
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
.env("HOME", fixture.config_dir_path())
.arg("config")
.arg("use")
.arg("production");
.arg("list");
cmd.assert().success();
// 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"));
// JSON output contains curly braces
cmd.assert().success().stdout(predicate::str::contains("{"));
}
#[tokio::test]
@@ -443,7 +450,7 @@ async fn test_config_list_all_keys() {
cmd.assert()
.success()
.stdout(predicate::str::contains("api_url"))
.stdout(predicate::str::contains("output_format"))
.stdout(predicate::str::contains("format"))
.stdout(predicate::str::contains("auth_token"));
}

View File

@@ -55,6 +55,8 @@ utoipa = { workspace = true }
# JWT
jsonwebtoken = { workspace = true }
hmac = { workspace = true }
signature = { workspace = true }
# Encryption
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)]
mod tests {
use super::*;
use crate::auth::crypto_provider;
fn test_config() -> JwtConfig {
crypto_provider::install();
JwtConfig {
secret: "test_secret_key_for_testing".to_string(),
access_token_expiration: 3600,
@@ -260,6 +262,7 @@ mod tests {
#[test]
fn test_generate_and_validate_access_token() {
let config = test_config();
let token =
generate_access_token(123, "testuser", &config).expect("Failed to generate token");
@@ -293,6 +296,7 @@ mod tests {
#[test]
fn test_token_with_wrong_secret() {
let config = test_config();
let token = generate_access_token(789, "user", &config).expect("Failed to generate token");
let wrong_config = JwtConfig {
@@ -306,6 +310,7 @@ mod tests {
#[test]
fn test_expired_token() {
crypto_provider::install();
let now = Utc::now().timestamp();
let expired_claims = Claims {
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
//! tokens), and the sensor service (for sensor tokens).
pub mod crypto_provider;
pub mod jwt;
pub use crypto_provider::install as install_crypto_provider;
pub use jwt::{
extract_token_from_header, generate_access_token, generate_execution_token,
generate_refresh_token, generate_sensor_token, generate_token, validate_token, Claims,

View File

@@ -295,6 +295,22 @@ pub struct SecurityConfig {
/// Enable authentication
#[serde(default = "default_true")]
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 {
@@ -305,6 +321,170 @@ fn default_jwt_refresh_expiration() -> u64 {
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.
/// Required when `enabled` is true; ignored otherwise.
#[serde(default)]
pub discovery_url: Option<String>,
/// Confidential client ID.
/// Required when `enabled` is true; ignored otherwise.
#[serde(default)]
pub client_id: Option<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.
/// Required when `enabled` is true; ignored otherwise.
#[serde(default)]
pub redirect_uri: Option<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").
/// Required when `enabled` is true; ignored otherwise.
#[serde(default)]
pub url: Option<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
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerConfig {
@@ -505,6 +685,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
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutorConfig {
@@ -598,6 +787,9 @@ pub struct Config {
/// Executor configuration (optional, for executor service)
pub executor: Option<ExecutorConfig>,
/// Agent configuration (optional, for agent binary distribution)
pub agent: Option<AgentConfig>,
}
fn default_service_name() -> String {
@@ -676,6 +868,10 @@ impl Default for SecurityConfig {
jwt_refresh_expiration: default_jwt_refresh_expiration(),
encryption_key: None,
enable_auth: true,
allow_self_registration: false,
login_page: LoginPageConfig::default(),
oidc: None,
ldap: None,
}
}
}
@@ -795,6 +991,53 @@ impl Config {
));
}
if let Some(oidc) = &self.security.oidc {
if oidc.enabled {
if oidc
.discovery_url
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
return Err(crate::Error::validation(
"OIDC discovery URL is required when OIDC is enabled",
));
}
if oidc.client_id.as_deref().unwrap_or("").trim().is_empty() {
return Err(crate::Error::validation(
"OIDC client ID is required 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.as_deref().unwrap_or("").trim().is_empty() {
return Err(crate::Error::validation(
"OIDC redirect URI is required when OIDC is enabled",
));
}
}
}
if let Some(ldap) = &self.security.ldap {
if ldap.enabled {
if ldap.url.as_deref().unwrap_or("").trim().is_empty() {
return Err(crate::Error::validation(
"LDAP server URL is required when LDAP is enabled",
));
}
}
}
// Validate encryption key if provided
if let Some(ref key) = self.security.encryption_key {
if key.len() < 32 {
@@ -859,6 +1102,7 @@ mod tests {
notifier: None,
pack_registry: PackRegistryConfig::default(),
executor: None,
agent: None,
};
assert_eq!(config.service_name, "attune");
@@ -924,6 +1168,10 @@ mod tests {
jwt_refresh_expiration: 604800,
encryption_key: Some("a".repeat(32)),
enable_auth: true,
allow_self_registration: false,
login_page: LoginPageConfig::default(),
oidc: None,
ldap: None,
},
worker: None,
sensor: None,
@@ -933,6 +1181,7 @@ mod tests {
notifier: None,
pack_registry: PackRegistryConfig::default(),
executor: None,
agent: None,
};
assert!(config.validate().is_ok());
@@ -946,4 +1195,127 @@ mod tests {
config.security.jwt_secret = None;
assert!(config.validate().is_err());
}
#[test]
fn test_oidc_config_disabled_no_urls_required() {
let yaml = r#"
enabled: false
"#;
let cfg: OidcConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert!(!cfg.enabled);
assert!(cfg.discovery_url.is_none());
assert!(cfg.client_id.is_none());
assert!(cfg.redirect_uri.is_none());
assert!(cfg.client_secret.is_none());
assert_eq!(cfg.provider_name, "oidc");
}
#[test]
fn test_ldap_config_disabled_no_url_required() {
let yaml = r#"
enabled: false
"#;
let cfg: LdapConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert!(!cfg.enabled);
assert!(cfg.url.is_none());
assert_eq!(cfg.provider_name, "ldap");
}
#[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.as_deref(), Some("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.as_deref(), Some("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);
}
}

View File

@@ -2,6 +2,14 @@
//!
//! This module provides functions for encrypting and decrypting secret values
//! using AES-256-GCM encryption with randomly generated nonces.
//!
//! ## JSON value encryption
//!
//! [`encrypt_json`] / [`decrypt_json`] operate on [`serde_json::Value`] values.
//! The JSON value is serialised to its compact string form before encryption,
//! and the resulting ciphertext is stored as a JSON string (`Value::String`).
//! This means the JSONB column always holds a plain JSON string when encrypted,
//! and the original structured value is recovered after decryption.
use crate::{Error, Result};
use aes_gcm::{
@@ -9,6 +17,7 @@ use aes_gcm::{
Aes256Gcm, Key, Nonce,
};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};
/// Size of the nonce in bytes (96 bits for AES-GCM)
@@ -55,6 +64,33 @@ pub fn encrypt(plaintext: &str, encryption_key: &str) -> Result<String> {
Ok(BASE64.encode(&result))
}
/// Encrypt a [`JsonValue`] using AES-256-GCM.
///
/// The value is first serialised to its compact JSON string representation,
/// then encrypted with [`encrypt`]. The returned value is a
/// [`JsonValue::String`] containing the base64 ciphertext, suitable for
/// storage in a JSONB column.
pub fn encrypt_json(value: &JsonValue, encryption_key: &str) -> Result<JsonValue> {
let plaintext = serde_json::to_string(value)
.map_err(|e| Error::encryption(format!("Failed to serialise JSON for encryption: {e}")))?;
let ciphertext = encrypt(&plaintext, encryption_key)?;
Ok(JsonValue::String(ciphertext))
}
/// Decrypt a [`JsonValue`] that was previously encrypted with [`encrypt_json`].
///
/// The input must be a [`JsonValue::String`] containing a base64 ciphertext.
/// After decryption the JSON string is parsed back into the original
/// structured [`JsonValue`].
pub fn decrypt_json(value: &JsonValue, encryption_key: &str) -> Result<JsonValue> {
let ciphertext = value
.as_str()
.ok_or_else(|| Error::encryption("Encrypted JSON value must be a string"))?;
let plaintext = decrypt(ciphertext, encryption_key)?;
serde_json::from_str(&plaintext)
.map_err(|e| Error::encryption(format!("Failed to parse decrypted JSON: {e}")))
}
/// Decrypt a ciphertext value using AES-256-GCM
///
/// The ciphertext should be base64-encoded and contain: nonce || encrypted_data || tag
@@ -226,4 +262,61 @@ mod tests {
assert_eq!(key1, key2);
assert_eq!(key1.len(), 32); // 256 bits
}
// ── JSON encryption tests ──────────────────────────────────────
#[test]
fn test_encrypt_decrypt_json_string() {
let value = serde_json::json!("my_secret_token");
let encrypted = encrypt_json(&value, TEST_KEY).expect("encrypt_json should succeed");
assert!(encrypted.is_string(), "encrypted JSON should be a string");
let decrypted = decrypt_json(&encrypted, TEST_KEY).expect("decrypt_json should succeed");
assert_eq!(value, decrypted);
}
#[test]
fn test_encrypt_decrypt_json_object() {
let value = serde_json::json!({"user": "admin", "password": "s3cret", "port": 5432});
let encrypted = encrypt_json(&value, TEST_KEY).expect("encrypt_json should succeed");
let decrypted = decrypt_json(&encrypted, TEST_KEY).expect("decrypt_json should succeed");
assert_eq!(value, decrypted);
}
#[test]
fn test_encrypt_decrypt_json_array() {
let value = serde_json::json!(["token1", "token2", 42, true, null]);
let encrypted = encrypt_json(&value, TEST_KEY).expect("encrypt_json should succeed");
let decrypted = decrypt_json(&encrypted, TEST_KEY).expect("decrypt_json should succeed");
assert_eq!(value, decrypted);
}
#[test]
fn test_encrypt_decrypt_json_number() {
let value = serde_json::json!(42);
let encrypted = encrypt_json(&value, TEST_KEY).unwrap();
let decrypted = decrypt_json(&encrypted, TEST_KEY).unwrap();
assert_eq!(value, decrypted);
}
#[test]
fn test_encrypt_decrypt_json_bool() {
let value = serde_json::json!(true);
let encrypted = encrypt_json(&value, TEST_KEY).unwrap();
let decrypted = decrypt_json(&encrypted, TEST_KEY).unwrap();
assert_eq!(value, decrypted);
}
#[test]
fn test_decrypt_json_wrong_key_fails() {
let value = serde_json::json!({"secret": "data"});
let encrypted = encrypt_json(&value, TEST_KEY).unwrap();
let wrong = "wrong_key_that_is_also_32_chars_long!!!";
assert!(decrypt_json(&encrypted, wrong).is_err());
}
#[test]
fn test_decrypt_json_non_string_fails() {
let not_encrypted = serde_json::json!(42);
assert!(decrypt_json(&not_encrypted, TEST_KEY).is_err());
}
}

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