Compare commits
250 Commits
fix/e2e-to
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
813bc46640 | ||
|
|
5682be366c | ||
|
|
dbdd398d90 | ||
|
|
0dd03af0c6 | ||
|
|
ef16d48a8c | ||
|
|
faf04174c4 | ||
|
|
8b70d52d43 | ||
|
|
5592f1c97e | ||
|
|
be0c618672 | ||
|
|
eae2ee08f1 | ||
|
|
6b2dcd41a6 | ||
|
|
35345e62de | ||
|
|
e4a58dc140 | ||
|
|
8fc78cd07f | ||
|
|
80fbf2e642 | ||
|
|
8e68f3a1cb | ||
|
|
4bf518c86f | ||
|
|
a09c346e83 | ||
|
|
789325b6db | ||
|
|
977abc25d0 | ||
|
|
345db4a076 | ||
|
|
2445074c87 | ||
|
|
77397e4cb3 | ||
|
|
27bda66be6 | ||
|
|
a7fb7ea23c | ||
|
|
95d6b95272 | ||
|
|
36272c8dac | ||
|
|
73c634ae0d | ||
|
|
612074af04 | ||
|
|
b5ce4cec01 | ||
|
|
251d7fe3dd | ||
|
|
8f35050499 | ||
|
|
1fc4080800 | ||
|
|
fa79b4ee45 | ||
|
|
735b2daedf | ||
|
|
c859c9aab2 | ||
|
|
966b209d2e | ||
|
|
3de75fe4a8 | ||
|
|
80a0d31cd2 | ||
|
|
58bf0ab3ec | ||
|
|
b086713e3d | ||
|
|
01e4e5a8f5 | ||
|
|
a2360a9464 | ||
|
|
155c5471b7 | ||
|
|
fc70f5e224 | ||
|
|
8b62b137c0 | ||
|
|
590b98b60f | ||
|
|
31e6fc964d | ||
|
|
bdcd3dc7f4 | ||
|
|
d08df88df5 | ||
|
|
2fc9476f9e | ||
|
|
fcd9451ab1 | ||
|
|
3da70a66c7 | ||
|
|
bc2538b5b5 | ||
|
|
486cc44a06 | ||
|
|
5e6bb762f4 | ||
|
|
f1b480f2eb | ||
|
|
aa3f0531f0 | ||
|
|
aeebdd2f3f | ||
|
|
eaf5ace30f | ||
|
|
bd9c3fa4f0 | ||
|
|
5a630e1d9f | ||
|
|
74d5874384 | ||
|
|
41a21c0551 | ||
|
|
a55c2f112e | ||
|
|
44f992cbb0 | ||
|
|
c8c4f012ff | ||
|
|
c03c0c83c5 | ||
|
|
a3c4267f6c | ||
|
|
5e1bda1f32 | ||
|
|
86448beb06 | ||
|
|
b8b047368e | ||
|
|
0f05c270d1 | ||
|
|
636106e9ea | ||
|
|
1a69778c0c | ||
|
|
fd4336e883 | ||
|
|
c06a9e801e | ||
|
|
4b4b3148b7 | ||
|
|
bbfdb1bf47 | ||
|
|
87292fc4e9 | ||
|
|
6ec034fb1d | ||
|
|
3f272daf04 | ||
|
|
2e3263002a | ||
|
|
8d71620e11 | ||
|
|
f0e18d263b | ||
|
|
e2bfe633a7 | ||
|
|
62710b5d81 | ||
|
|
b8eced9d91 | ||
|
|
cb3d458cfd | ||
|
|
dcc387c345 | ||
|
|
0078ae8b26 | ||
|
|
76926de369 | ||
|
|
86b51df50d | ||
|
|
ffa8b02506 | ||
|
|
74f7a67be7 | ||
|
|
f673922d89 | ||
|
|
30fe160f16 | ||
|
|
b4eb226d20 | ||
|
|
5a820d7262 | ||
|
|
5bb1ccad5e | ||
|
|
5bd72956b1 | ||
|
|
b33c87e5c4 | ||
|
|
47072c481d | ||
|
|
2cce051dc4 | ||
|
|
ba97c35aaf | ||
|
|
f1730a1ba0 | ||
|
|
ae98063f87 | ||
|
|
c361048022 | ||
|
|
50205e7b7e | ||
|
|
ccd3644aa4 | ||
|
|
19ba6dbfb4 | ||
|
|
9626d15863 | ||
|
|
1c7e8886d8 | ||
|
|
ae8f0be748 | ||
|
|
137103a429 | ||
|
|
849bd367b9 | ||
|
|
6b1831aea2 | ||
|
|
186b6f1dcc | ||
|
|
da83222ea1 | ||
|
|
e2457efcbe | ||
|
|
9dc9191cb2 | ||
|
|
05373814b2 | ||
|
|
aa5bb3e8cc | ||
|
|
ddb3589c49 | ||
|
|
456e972336 | ||
|
|
24d834a102 | ||
|
|
c21e82fb9f | ||
|
|
2bc3fb677b | ||
|
|
9a4b83dfcf | ||
|
|
f04eaed0b7 | ||
|
|
0210ace406 | ||
|
|
cf296cd2fd | ||
|
|
50d7a0a501 | ||
|
|
16267cec06 | ||
|
|
f60fa36b0b | ||
|
|
86c57e2e43 | ||
|
|
cdef97a634 | ||
|
|
bfa4b48930 | ||
|
|
fa0702870a | ||
|
|
247e2836b8 | ||
|
|
95f20b4add | ||
|
|
483e4cb116 | ||
|
|
3df37b2b85 | ||
|
|
b74e1a401b | ||
|
|
9601046e29 | ||
|
|
4b5bf72a66 | ||
|
|
32cb6c1f1f | ||
|
|
18ac9f5afa | ||
|
|
b048daa700 | ||
|
|
a977ff6a32 | ||
|
|
8f1ba39a8a | ||
|
|
96277e1e7c | ||
|
|
469e8526d7 | ||
|
|
0a583aa467 | ||
|
|
95007c124e | ||
|
|
c492a08775 | ||
|
|
72802de644 | ||
|
|
547004e32d | ||
|
|
7f51087d5d | ||
|
|
e885ed5412 | ||
|
|
9c6d690792 | ||
|
|
17221a2683 | ||
|
|
e7c0485dbf | ||
|
|
3479afc56b | ||
|
|
779d906d75 | ||
|
|
f552238586 | ||
|
|
a00454e5de | ||
|
|
5efb068b9f | ||
|
|
78a468de83 | ||
|
|
61352c7e5e | ||
|
|
5d1d4b3ff6 | ||
|
|
af525e71bd | ||
|
|
0973344515 | ||
|
|
b79d8ef47d | ||
|
|
d9766bcbc7 | ||
|
|
d1de43e919 | ||
|
|
f1d5f5c34e | ||
|
|
e53e2f583d | ||
|
|
529725ebb6 | ||
|
|
a049397bb0 | ||
|
|
82b4579317 | ||
|
|
e62e073970 | ||
|
|
8f966783fe | ||
|
|
8b68a7d7e8 | ||
|
|
4e25a142bd | ||
|
|
52da8a8704 | ||
|
|
c8acac1d20 | ||
|
|
b53eabb786 | ||
|
|
1844bef132 | ||
|
|
7fef098b48 | ||
|
|
3ef3038eb7 | ||
|
|
1662d7806a | ||
|
|
8a559ea178 | ||
|
|
da03fda1d6 | ||
|
|
65049a6262 | ||
|
|
99e7100f0b | ||
|
|
9598db149b | ||
|
|
c29e4b9c53 | ||
|
|
701ec90b56 | ||
|
|
719a17432f | ||
|
|
43ed4e8e7f | ||
|
|
48178c5f60 | ||
|
|
27dc04819f | ||
|
|
181622a84a | ||
|
|
34dcae98a2 | ||
|
|
e517f6dc8c | ||
|
|
b2822c8435 | ||
|
|
b46667c309 | ||
|
|
ca360d84cb | ||
|
|
79f8c9d420 | ||
|
|
bb68e5e50a | ||
|
|
11ac168839 | ||
|
|
30441549d5 | ||
|
|
c914758c09 | ||
|
|
e6b4792389 | ||
|
|
b588fc9d35 | ||
|
|
6bd0f4699d | ||
|
|
9ee85993d8 | ||
|
|
343f6d3b4d | ||
|
|
35dbd9f368 | ||
|
|
6db057fb08 | ||
|
|
49c4fb41ce | ||
|
|
8725939172 | ||
|
|
eea1d8984e | ||
|
|
a432091191 | ||
|
|
2b27012f56 | ||
|
|
c900ca5bbf | ||
|
|
695987d6b7 | ||
|
|
d4b5632db1 | ||
|
|
345f265062 | ||
|
|
aa3995ae05 | ||
|
|
a27863d4a6 | ||
|
|
b3f05dfe2a | ||
|
|
5e62f4acfe | ||
|
|
73c8c289c1 | ||
|
|
816f143ffd | ||
|
|
2ba476aac8 | ||
|
|
79153b2063 | ||
|
|
9e1c24f729 | ||
|
|
8c805a07d0 | ||
|
|
bfab7c781d | ||
|
|
4223d3c320 | ||
|
|
55cf995eda | ||
|
|
627996cde7 | ||
|
|
be5df06a48 | ||
|
|
b8a4f30219 | ||
|
|
d9b3e9973c | ||
|
|
3b650a2b14 | ||
|
|
c600177fd6 | ||
|
|
51f14fa2b1 |
@@ -37,6 +37,25 @@
|
|||||||
"Bash(dig *)",
|
"Bash(dig *)",
|
||||||
"Bash(host *)",
|
"Bash(host *)",
|
||||||
|
|
||||||
|
"Bash(./target/debug/pic plan *)",
|
||||||
|
"Bash(./target/debug/pic config *)",
|
||||||
|
"Bash(./target/debug/pic whoami)",
|
||||||
|
"Bash(./target/debug/pic pull *)",
|
||||||
|
"Bash(./target/debug/pic apps domains ls *)",
|
||||||
|
"Bash(./target/debug/pic routes ls *)",
|
||||||
|
"Bash(./target/debug/pic kv get *)",
|
||||||
|
"Bash(./target/debug/pic kv ls *)",
|
||||||
|
"Bash(./target/debug/pic dead-letters ls *)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic plan *)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic config *)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic whoami)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic pull *)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic apps domains ls *)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic routes ls *)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic kv get *)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic kv ls *)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic dead-letters ls *)",
|
||||||
|
|
||||||
"Bash(ls)",
|
"Bash(ls)",
|
||||||
"Bash(ls *)",
|
"Bash(ls *)",
|
||||||
"Bash(pwd)",
|
"Bash(pwd)",
|
||||||
|
|||||||
53
.github/workflows/ci.yml
vendored
53
.github/workflows/ci.yml
vendored
@@ -10,6 +10,11 @@ env:
|
|||||||
# Matches what docker-compose produces locally; the schema-snapshot
|
# Matches what docker-compose produces locally; the schema-snapshot
|
||||||
# guardrail and any other DB-backed tests run against this service.
|
# guardrail and any other DB-backed tests run against this service.
|
||||||
DATABASE_URL: postgres://picloud:picloud@localhost:5432/picloud
|
DATABASE_URL: postgres://picloud:picloud@localhost:5432/picloud
|
||||||
|
# The DB-backed suites skip themselves when DATABASE_URL is unset. In CI it is
|
||||||
|
# always set, so this makes that skip a hard error: if the database ever goes
|
||||||
|
# missing (a broken service container, a lost env), the build goes RED instead
|
||||||
|
# of green-but-empty. Honored by picloud_test_support::abort_if_db_required.
|
||||||
|
PICLOUD_REQUIRE_DB: "1"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rust:
|
rust:
|
||||||
@@ -47,11 +52,49 @@ jobs:
|
|||||||
- name: Clippy
|
- name: Clippy
|
||||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
# Runs the whole workspace, including the schema-snapshot guardrail
|
# `--include-ignored` is load-bearing. Every DB-backed integration test is
|
||||||
# (it picks up DATABASE_URL from the env above and the postgres
|
# `#[ignore = "needs DATABASE_URL..."]`, and CI omitted the flag — so CI ran
|
||||||
# service; without a DB it would skip cleanly).
|
# 927 tests and silently skipped 237, among them ALL of authz.rs and api.rs
|
||||||
- name: Test
|
# and the entire CLI journey suite. The isolation and RBAC tests existed but
|
||||||
run: cargo test --workspace
|
# never executed (AUDIT.md F-Q-014). CI does provide Postgres, so run them.
|
||||||
|
#
|
||||||
|
# `--all-targets` (not a bare `cargo test`) is deliberate: it runs the lib,
|
||||||
|
# bins, and integration tests but NOT doctests. `-- --include-ignored`
|
||||||
|
# un-ignores not just `#[ignore]` tests but also ` ```ignore ` DOCTESTS,
|
||||||
|
# which are illustrative pseudocode that does not compile — so a bare
|
||||||
|
# `cargo test ... -- --include-ignored` fails on them. Doctests run in their
|
||||||
|
# own step below, without the flag. (Clippy already uses `--all-targets` for
|
||||||
|
# the same doctest-excluding reason.)
|
||||||
|
#
|
||||||
|
# The CLI journeys are a SEPARATE step, and deliberately not part of the
|
||||||
|
# workspace run: they spawn a real picloud whose dispatcher/orchestrator
|
||||||
|
# claim loops are global by design (one instance owns one database). Run
|
||||||
|
# concurrently with the manager-core suites — which share this database —
|
||||||
|
# it would claim their outbox and workflow rows out from under them. Keeping
|
||||||
|
# the steps sequential keeps that live server off the shared DB while the
|
||||||
|
# other suites are using it.
|
||||||
|
- name: Test (workspace, including DB-backed tests)
|
||||||
|
run: cargo test --workspace --exclude picloud-cli --all-targets -- --include-ignored
|
||||||
|
|
||||||
|
# Doctests, run WITHOUT --include-ignored so ` ```ignore ` snippets stay
|
||||||
|
# ignored. `--all-targets` above skips these, so nothing else covers them.
|
||||||
|
- name: Doctests
|
||||||
|
run: cargo test --workspace --doc
|
||||||
|
|
||||||
|
# The journey harness execs the prebuilt target/debug/picloud and does NOT
|
||||||
|
# rebuild it, so a stale binary would silently test old server code.
|
||||||
|
- name: Build picloud (the journey harness execs this binary)
|
||||||
|
run: cargo build -p picloud
|
||||||
|
|
||||||
|
# The spawned server inherits this env; without a secret key it aborts at
|
||||||
|
# startup and every journey fails as "/healthz never returned 200".
|
||||||
|
# `--all-targets` for the same reason as above (the journeys are `#[ignore]`
|
||||||
|
# integration tests; picloud-cli's doctests, if any, run in the Doctests step).
|
||||||
|
- name: Test (CLI journeys)
|
||||||
|
env:
|
||||||
|
PICLOUD_DEV_MODE: "true"
|
||||||
|
PICLOUD_DEV_INSECURE_KEY: i-understand-this-is-insecure
|
||||||
|
run: cargo test -p picloud-cli --all-targets -- --include-ignored
|
||||||
|
|
||||||
dashboard:
|
dashboard:
|
||||||
name: Dashboard — check
|
name: Dashboard — check
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -26,8 +26,11 @@ docker-compose.override.yml
|
|||||||
config.local.toml
|
config.local.toml
|
||||||
/data
|
/data
|
||||||
# Files-root blob storage created when integration tests run build_app
|
# Files-root blob storage created when integration tests run build_app
|
||||||
# from the picloud crate dir (PICLOUD_FILES_ROOT default ./data).
|
# from a crate dir (PICLOUD_FILES_ROOT default ./data). The CLI journey
|
||||||
|
# harness spawns the server from the picloud-cli dir, so it lands there too
|
||||||
|
# (the §11.6 group-files journey is the first CLI test to write blobs).
|
||||||
/crates/picloud/data
|
/crates/picloud/data
|
||||||
|
/crates/picloud-cli/data
|
||||||
/postgres-data
|
/postgres-data
|
||||||
|
|
||||||
# Dashboard
|
# Dashboard
|
||||||
|
|||||||
57
CHANGELOG.md
57
CHANGELOG.md
@@ -1,5 +1,62 @@
|
|||||||
# PiCloud Changelog
|
# PiCloud Changelog
|
||||||
|
|
||||||
|
## Unreleased — CMS proof-of-concept remediation
|
||||||
|
|
||||||
|
Fixes surfaced by building a full CMS on PiCloud (`examples/cms-poc/`). Pure
|
||||||
|
additive surface + one migration (`0077_apps_cors.sql`), no destructive changes.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **502 info-leak closed on user routes.** An uncaught script error (incl. a
|
||||||
|
`throw`) on a user route returned HTTP 502 whose body embedded the app UUID,
|
||||||
|
script function names, and source line/col. The 2026-06-11 audit scrub had
|
||||||
|
only covered the execute-by-id path; the user-route (inbox) path still leaked.
|
||||||
|
Both paths now share `scrub_runtime_detail` — the raw detail is logged
|
||||||
|
server-side under a correlation id and the client sees only
|
||||||
|
`script execution error (ref: <uuid>)`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`docs::find` `$contains` operator** — array-membership via JSONB `@>`, e.g.
|
||||||
|
`docs::find("posts", #{ tags: #{ "$contains": "rust" } })` finds every post
|
||||||
|
tagged `rust`. The inverse of `$in`. Covers per-app and group-shared docs.
|
||||||
|
- **App-user role management from the API/CLI** — `pic users add-role --app <app>
|
||||||
|
<user_id> <role>` / `rm-role`, backed by `POST`/`DELETE
|
||||||
|
/api/v1/admin/apps/{id}/users/{user_id}/roles[/{role}]` (`AppUsersAdmin`).
|
||||||
|
Role assignment was previously reachable only from a Rhai script.
|
||||||
|
- **Per-app CORS** — `pic apps cors set <app> <origins…>` (or `*`), stored on
|
||||||
|
`apps.cors_allowed_origins`. The orchestrator echoes an allowed `Origin` on
|
||||||
|
every response and answers the `OPTIONS` preflight. Empty = off (unchanged).
|
||||||
|
- **Non-JSON request bodies** — a user route no longer 400s on a non-JSON body.
|
||||||
|
`application/x-www-form-urlencoded` → `ctx.request.body` object, `text/*` →
|
||||||
|
string, other types → base64 string; `ctx.request.content_type` added as a
|
||||||
|
convenience. JSON (or unspecified) is unchanged; malformed JSON still 400s.
|
||||||
|
- **Binary responses** — a script may return `#{ statusCode, headers,
|
||||||
|
body_base64 }`; the platform decodes the base64 and returns raw bytes with the
|
||||||
|
script-set `Content-Type` (default `application/octet-stream`).
|
||||||
|
- **`workflow::run_status(run_id)` SDK** (F-038) — the script that started a run
|
||||||
|
can now poll it: returns `#{ status, output, error, steps: #{ name: status } }`
|
||||||
|
or `()` when no such run belongs to the app. Read-only, same-app scoped (the
|
||||||
|
run's `app_id` is the isolation boundary), same `AppInvoke` gate as
|
||||||
|
`workflow::start`. Closes the gap where run status was platform-admin-API only.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`pic apply` "app not found" is now actionable** — points at
|
||||||
|
`pic apps create <slug>` (apply reconciles groups but never creates an app).
|
||||||
|
- **Interceptor- and workflow-step-bound scripts no longer warned "no route or
|
||||||
|
trigger"** at plan time — an `[[interceptors]]` binding (F-021) *and* a
|
||||||
|
`[[workflows]]` step `function` (F-037) now count as reachability.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- **Workflow `skipped` ≠ `failed` (F-039, by design).** A step whose `when`
|
||||||
|
evaluates false is `skipped` and counts as *satisfied-but-empty* for its
|
||||||
|
dependents — so a step downstream of a skipped one still runs. To gate a
|
||||||
|
dependent on an upstream actually having run, give it its own `when`
|
||||||
|
(e.g. `when = "steps.publish.output.published == true"`), or have the upstream
|
||||||
|
step return an explicit outcome the dependent checks.
|
||||||
|
|
||||||
## v1.1.9 — Durable Queues & Function Composition (unreleased)
|
## v1.1.9 — Durable Queues & Function Composition (unreleased)
|
||||||
|
|
||||||
Per-app durable queues + same-app function composition + caller-
|
Per-app durable queues + same-app function composition + caller-
|
||||||
|
|||||||
12
Cargo.lock
generated
12
Cargo.lock
generated
@@ -1939,6 +1939,7 @@ dependencies = [
|
|||||||
"picloud-manager-core",
|
"picloud-manager-core",
|
||||||
"picloud-orchestrator-core",
|
"picloud-orchestrator-core",
|
||||||
"picloud-shared",
|
"picloud-shared",
|
||||||
|
"picloud-test-support",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
@@ -1964,6 +1965,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"picloud-shared",
|
"picloud-shared",
|
||||||
|
"picloud-test-support",
|
||||||
"postgres",
|
"postgres",
|
||||||
"predicates",
|
"predicates",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -2043,6 +2045,7 @@ dependencies = [
|
|||||||
"picloud-executor-core",
|
"picloud-executor-core",
|
||||||
"picloud-orchestrator-core",
|
"picloud-orchestrator-core",
|
||||||
"picloud-shared",
|
"picloud-shared",
|
||||||
|
"picloud-test-support",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2074,6 +2077,7 @@ version = "1.1.9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"lru",
|
"lru",
|
||||||
"picloud-executor-core",
|
"picloud-executor-core",
|
||||||
@@ -2110,6 +2114,14 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "picloud-test-support"
|
||||||
|
version = "1.1.9"
|
||||||
|
dependencies = [
|
||||||
|
"sqlx",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ members = [
|
|||||||
"crates/picloud-orchestrator",
|
"crates/picloud-orchestrator",
|
||||||
"crates/picloud-executor",
|
"crates/picloud-executor",
|
||||||
"crates/picloud-cli",
|
"crates/picloud-cli",
|
||||||
|
"crates/test-support",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -63,6 +64,7 @@ futures = "0.3"
|
|||||||
rhai = { version = "=1.24", features = ["sync", "serde"] }
|
rhai = { version = "=1.24", features = ["sync", "serde"] }
|
||||||
|
|
||||||
# Postgres (manager-core only — others stay DB-free)
|
# Postgres (manager-core only — others stay DB-free)
|
||||||
|
picloud-test-support = { path = "crates/test-support" }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "macros", "migrate"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "macros", "migrate"] }
|
||||||
|
|
||||||
# Config
|
# Config
|
||||||
|
|||||||
138
E2E_STASH_REPORT.md
Normal file
138
E2E_STASH_REPORT.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# E2E Test #2 — "Stash" (paste + file-drop) + security matrix
|
||||||
|
|
||||||
|
**Date:** 2026-06-13 · **Build:** v1.1.9 · **Instance:** host `picloud` on `127.0.0.1:8099`
|
||||||
|
(dev mode, `PICLOUD_FILES_MAX_FILE_SIZE_BYTES=1048576`, SSRF guard on, SMTP unset), Postgres in
|
||||||
|
Docker. Driven entirely through the `pic` CLI; data-plane traffic via `curl`.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Second end-to-end pass. The first test (To-Do, `E2E_TODO_REPORT.md`) covered users/docs/pubsub/
|
||||||
|
cron/routes/domains/topics. This one builds a **new** app to exercise the *untested* half of the
|
||||||
|
platform — **kv, files, queue, invoke, secrets, api-keys, dead-letters, async + two-param routes**
|
||||||
|
— and then runs a **10-probe security matrix**. Findings: feature/CLI gaps + security verdicts.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**Every feature worked** end-to-end, and **every security control held except one Low-severity
|
||||||
|
gap** (reserved-path validation is case-sensitive). Highlights of what's *good*: cross-app
|
||||||
|
isolation is airtight, the SSRF guard blocks cloud-metadata, size/op caps fire before authz, and
|
||||||
|
internal script errors are scrubbed from responses with a correlation id. Two notable
|
||||||
|
**observability/ergonomics gaps** (trigger executions invisible to `pic logs`; the `.trim()`/`.replace()`
|
||||||
|
return-`()` footgun) and the expected CLI-coverage gaps round it out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The app — "Stash" (built CLI-only, no raw admin curl)
|
||||||
|
|
||||||
|
A pastebin with file attachments and a background word-count worker. 9 scripts, 6 routes, a queue
|
||||||
|
consumer trigger, a dead-letter trigger, 2 API keys, 1 secret — all via `pic`.
|
||||||
|
|
||||||
|
| Feature exercised | How | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| **kv** | paste store, hit counter, `/stats` aggregates, event log | ✅ get/set/list round-trip |
|
||||||
|
| **files** | `files::create` from base64 blob + `get`/`head`, two-param download route | ✅ bytes round-trip (`"hello attachment bytes"`, size 22) |
|
||||||
|
| **queue** | `queue::enqueue("ingest")` + `create-from-json --kind queue` consumer | ✅ worker fired |
|
||||||
|
| **invoke** | worker → `invoke("analyzer", #{text})` function-to-function | ✅ returns word count |
|
||||||
|
| **secrets** | `pic secrets set` (stdin) + `secrets::get` to gate `/stats` | ✅ 401 w/o token, 200 with |
|
||||||
|
| **async route** | `routes create --dispatch async` for `POST /events` | ✅ **202** + execution_id |
|
||||||
|
| **dead-letters** | poison job → 3 attempts → DL row; `pic dead-letters replay` | ✅ captured + replayed (+9 words) |
|
||||||
|
| **api-keys** | `pic api-keys mint --scope … [--app …]` | ✅ minted, scopes enforced (below) |
|
||||||
|
| **two-param route** | `/pastes/:code/files/:fid` | ✅ both params captured |
|
||||||
|
|
||||||
|
End-to-end stats after the run: `{total_pastes: 2, total_words: 13}` — proving queue → worker →
|
||||||
|
invoke → kv all fired on real Postgres rows + on-disk file bytes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security matrix (10 probes, all reproduced live)
|
||||||
|
|
||||||
|
| # | Probe | Verdict | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **S1** | Cross-app isolation | ✅ HOLDS | A script in app `evil` doing `kv…list()` / `secrets::get("admin-token")` / `files…list()` returned `{paste_keys:[], attachment_count:0, stolen_secret:null, my_secret_names:[]}`. `app_id` derives from `cx.app_id`; no SDK call takes a script-passed app_id. |
|
||||||
|
| **S2** | Secret confidentiality | ✅ HOLDS | `pic secrets ls` shows names only; the plaintext `sup3r-s3cret-admin` does **not** appear anywhere in the server log even though `/stats` reads it each request. |
|
||||||
|
| **S3** | API-key scope + app-binding | ✅ HOLDS | `script:read` key → **403** on `POST /apps` and `GET /admins`, **200** on in-scope `GET /scripts`. `app:admin` key bound to `stash` → **403** on app `evil`. `instance:admin` + `--app` → **422**. Unknown scope `bogus:scope` → rejected by CLI. |
|
||||||
|
| **S4** | Sandbox op-budget | ✅ HOLDS | `loop {}` → **HTTP 507 "execution exceeded operation budget"** in **0.30 s**; `/healthz` still `ok` (no hang). |
|
||||||
|
| **S5** | Value/size caps | ✅ HOLDS | Oversized kv write rejected (HTTP 502). Notably the detail (`"Length of string too large"`) was **scrubbed from the response** and logged with `correlation_id` — good error hygiene. Caps are checked before authz, so anonymous callers can't DoS. |
|
||||||
|
| **S6** | Reserved-path prefixes | ⚠️ **GAP (Low)** | `/api/x`, `/admin/x`, `/healthz`, `/version` → correctly **422**. But case variants `/API/v2/x`, `/Admin/x`, `/HEALTHZ` were **accepted**. See finding below. |
|
||||||
|
| **S7** | Files path traversal | ✅ HOLDS | `files::collection("../../../etc")` → `"invalid collection name: must not contain '/', '\\', '..', or NUL"`; a non-UUID file id → not found. No FS escape. |
|
||||||
|
| **S8** | docs filter injection | ✅ HOLDS | `docs::find(#{ "x'); DROP TABLE docs;--": 1 })` → ran safely, `count=0`; the `docs` table still exists (`to_regclass` = true). Values/paths are bound as `$N` params. |
|
||||||
|
| **S9** | SSRF via `http::` | ✅ HOLDS | `http::get("http://127.0.0.1:8099/healthz")` → `"blocked by SSRF policy: loopback"`; `http://169.254.169.254/…` (cloud metadata) → `"blocked by SSRF policy: link-local"`. |
|
||||||
|
| **S10** | Anonymous data-plane access | ℹ️ By design (caveat) | Every Stash route is public (no auth) yet freely uses kv/secrets/files/queue. `script_gate` returns `Ok` when `principal.is_none()` — the *script* is the gate. Correct per design, but a sharp threat-model edge (see note). |
|
||||||
|
|
||||||
|
### S6 — reserved-path validation is case-sensitive (Low)
|
||||||
|
**What:** route-creation rejects the exact reserved prefixes but accepts case variants:
|
||||||
|
```
|
||||||
|
pic routes create --path /api/x -> 422 "path '/api/x' is reserved"
|
||||||
|
pic routes create --path /API/v2/x -> Created route … (GET * /API/v2/x) # accepted
|
||||||
|
pic routes create --path /HEALTHZ -> Created route … # accepted
|
||||||
|
pic routes create --path /Admin/x -> Created route … # accepted
|
||||||
|
```
|
||||||
|
**Current impact — Low, *not* a full bypass:** path matching is case-*sensitive*, so a request to
|
||||||
|
the real lowercase `/api/v2/x` still 404s (the system namespace is safe); only the exact
|
||||||
|
mixed-case path serves the script (`GET /API/v2/x` → 200, `/HEALTHZ` → 200). Real `/healthz`
|
||||||
|
still returns `ok` from the top-level handler.
|
||||||
|
**Why it still matters:** (1) inconsistent enforcement of a stated security boundary; (2) lets a
|
||||||
|
tenant publish convincing look-alike paths (`/Admin/login`, `/API/v1/…`) for phishing or log
|
||||||
|
confusion; (3) **fragile** — method matching is already case-insensitive (`matcher.rs:275`); if
|
||||||
|
path matching is ever made case-insensitive too, this instantly becomes a real shadowing bypass
|
||||||
|
of `/api/`, `/admin/`, `/healthz`. The validation should be the durable guard.
|
||||||
|
**Fix:** normalize case before the reserved check in `orchestrator-core/src/routing/pattern.rs`
|
||||||
|
(`check_reserved` ~line 110) — compare `raw.to_ascii_lowercase()` against the reserved list (or
|
||||||
|
reject any case-insensitive match).
|
||||||
|
|
||||||
|
### S10 — anonymous public scripts have full app data-plane access (design caveat)
|
||||||
|
Not a bug, but worth surfacing for users: a *public* (unauthenticated) route's script can read/write
|
||||||
|
**all** of the app's kv, docs, files, secrets, and queue — the platform only gates *authenticated*
|
||||||
|
principals; for anonymous ingress the script itself must enforce access. A developer who assumes
|
||||||
|
"this route is public" ≠ "this code can read every secret in my app" could over-expose data. Worth
|
||||||
|
a prominent doc callout near the SDK auth model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature / CLI gaps
|
||||||
|
|
||||||
|
- **G1 — trigger executions are invisible to `pic logs`.** After successful queue-worker and
|
||||||
|
analyzer runs, `pic logs <worker>` and `pic logs <analyzer>` were **empty**. Trigger-dispatched
|
||||||
|
executions (queue/cron/dead-letter/invoke) don't surface in the per-script log tail — the only
|
||||||
|
built-in visibility into worker activity is **dead-letters (failures only)** or the script's own
|
||||||
|
side effects. This is a real observability gap for background workloads. *Suggest: include
|
||||||
|
trigger executions in the logs surface, or add a `pic logs --trigger`/events view.*
|
||||||
|
- **G2 — no `pic kv` / `files` / `queues` / `members` commands.** Data-plane stores are
|
||||||
|
script/HTTP-only (no admin CLI to inspect kv/files/queue contents; queues are read-only HTTP).
|
||||||
|
App **membership** (`/apps/{id}/members`) also has no CLI — multi-user app roles can't be managed
|
||||||
|
with `pic`. *(Confirmed absent via `pic <cmd> --help`.)*
|
||||||
|
- **G3 — no per-script sandbox/timeout flags in `pic deploy`/`scripts`.** `timeout_seconds`,
|
||||||
|
`memory_limit_mb`, and sandbox overrides are only settable via the raw scripts API or instance
|
||||||
|
env (`PICLOUD_SANDBOX_MAX_*`). A developer can't cap a single script's runtime from the CLI.
|
||||||
|
- **G4 — uneven trigger wrappers.** Only `kv`, `cron`, `dead-letter` have first-class
|
||||||
|
`pic triggers create-*` wrappers; `docs`/`files`/`pubsub`/`queue`/`email` require hand-built
|
||||||
|
`create-from-json --kind … --body '{…}'`. Works, but the developer must know each body schema.
|
||||||
|
- **G5 — `email::send` unusable in dev.** Returns `"email is not configured: set
|
||||||
|
PICLOUD_SMTP_HOST/USER/PASSWORD"`. Expected (no silent drop), but the email feature can't be
|
||||||
|
exercised at all without an SMTP relay — worth a documented local-dev fake/sink.
|
||||||
|
- **G6 — Rhai `.trim()` returns `()` (in-place mutation footgun).** Same family as `.replace()`
|
||||||
|
(already documented). `let t = text.trim()` sets `t` to unit; my analyzer dead-lettered with
|
||||||
|
`Function not found: split((), …)` until rewritten to `text.trim();` as a statement. *Suggest:
|
||||||
|
extend the existing stdlib footgun note to list `trim`/`to_upper`/`to_lower` etc., not just
|
||||||
|
`replace`.*
|
||||||
|
|
||||||
|
## Things done especially well (no action)
|
||||||
|
- Cross-app isolation has no script-controlled `app_id` anywhere — the boundary is structural.
|
||||||
|
- SSRF guard covers loopback **and** link-local (cloud-metadata `169.254.169.254`), and blocks at
|
||||||
|
every redirect hop.
|
||||||
|
- Size/operation caps are enforced **before** authz, so anonymous public scripts can't DoS the DB.
|
||||||
|
- Script runtime errors are **scrubbed** from the HTTP response and logged with a `correlation_id`
|
||||||
|
— no internal detail leaks to the caller.
|
||||||
|
- `deploy`/`apps create` honor `--output json` (the earlier F1 fix), so the whole build scripts
|
||||||
|
cleanly with captured ids.
|
||||||
|
|
||||||
|
## Reproduction / teardown
|
||||||
|
Scripts under `/tmp/stash/*.rhai`; apps `stash` + `evil` and their data persist in the dev
|
||||||
|
Postgres. Server: `target/debug/picloud` on `:8099` (see Phase 0 env). Teardown: kill the host
|
||||||
|
`picloud`; `docker compose down [-v]`. (Note: the S6 probe left harmless `/API/v2/x`, `/HEALTHZ`,
|
||||||
|
`/Admin/x`, `/Api/y` routes on `stash` — they vanish with the app.)
|
||||||
|
|
||||||
|
## Priority
|
||||||
|
**S6** is the only code-level finding (Low — confirm-and-harden the reserved-path check). **G1**
|
||||||
|
(trigger-execution observability) is the most impactful *developer-experience* gap for anyone
|
||||||
|
running background workers. Everything else is documented-behavior or known CLI-coverage gaps.
|
||||||
@@ -44,6 +44,14 @@ fn current_deadline() -> Option<Instant> {
|
|||||||
CURRENT_DEADLINE.with(Cell::get)
|
CURRENT_DEADLINE.with(Cell::get)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The current-thread execution deadline, if one is installed. Public so the
|
||||||
|
/// interceptor hook can compute a per-hook timeout as `min(ambient, now + t)` —
|
||||||
|
/// a hook must never be able to EXTEND its caller's deadline (§9.4 M5).
|
||||||
|
#[must_use]
|
||||||
|
pub fn ambient_deadline() -> Option<Instant> {
|
||||||
|
current_deadline()
|
||||||
|
}
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
ScriptId, ScriptValidator, SdkCallCx, Services, TriggerEvent, ValidatedScript, ValidationError,
|
ScriptId, ScriptValidator, SdkCallCx, Services, TriggerEvent, ValidatedScript, ValidationError,
|
||||||
@@ -57,7 +65,9 @@ use crate::module_resolver::{
|
|||||||
};
|
};
|
||||||
use crate::sandbox::Limits;
|
use crate::sandbox::Limits;
|
||||||
use crate::sdk;
|
use crate::sdk;
|
||||||
use crate::sdk::bridge::{dynamic_to_json, json_to_dynamic};
|
use crate::sdk::bridge::{
|
||||||
|
dynamic_to_json, dynamic_to_json_capped, json_to_dynamic, MAX_JSON_MATERIALIZE_BYTES,
|
||||||
|
};
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
|
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
|
||||||
};
|
};
|
||||||
@@ -278,6 +288,17 @@ impl Engine {
|
|||||||
/// cache hands compiled ASTs in directly; this path skips the
|
/// cache hands compiled ASTs in directly; this path skips the
|
||||||
/// per-call compile.
|
/// per-call compile.
|
||||||
pub fn execute_ast(&self, ast: &Arc<AST>, req: ExecRequest) -> Result<ExecResponse, ExecError> {
|
pub fn execute_ast(&self, ast: &Arc<AST>, req: ExecRequest) -> Result<ExecResponse, ExecError> {
|
||||||
|
// Audit #2: bound per-execution durable fan-out. The scope is
|
||||||
|
// re-entrancy aware — a fresh dispatched execution (a new task on a
|
||||||
|
// pooled thread) resets the budget, while a synchronous invoke() /
|
||||||
|
// interceptor re-entry nested in the SAME call stack shares it, so
|
||||||
|
// fan-out is counted across the whole synchronous chain. Held to the end
|
||||||
|
// of the call; its Drop re-zeroes the counter on the outermost exit.
|
||||||
|
let _emit_scope = crate::sdk::emit_budget::EmissionBudgetScope::enter();
|
||||||
|
// §9.4 M6: memoize interceptor chain resolution for this execution tree
|
||||||
|
// (same re-entrancy model as the emission budget); cleared at the
|
||||||
|
// outermost boundary so a pooled thread never reuses a foreign app's cache.
|
||||||
|
let _interceptor_cache = crate::sdk::interceptor::InterceptorCacheScope::enter();
|
||||||
let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
|
let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
|
||||||
let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
|
let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
let mut engine = build_engine(effective_limits, Some(logs.clone()));
|
let mut engine = build_engine(effective_limits, Some(logs.clone()));
|
||||||
@@ -300,11 +321,18 @@ impl Engine {
|
|||||||
// installed with the real per-call resolver. The resolver owns
|
// installed with the real per-call resolver. The resolver owns
|
||||||
// `cx.clone()` so cross-app isolation derives from this exact
|
// `cx.clone()` so cross-app isolation derives from this exact
|
||||||
// call's context, not from any script-passed argument.
|
// call's context, not from any script-passed argument.
|
||||||
|
// The lexical origin for `import` resolution (§5.5): the top-level
|
||||||
|
// script's defining node. Falls back to the executing app when the
|
||||||
|
// request predates Phase 4b (old payloads / cluster wire).
|
||||||
|
let default_origin = req
|
||||||
|
.script_owner
|
||||||
|
.unwrap_or(picloud_shared::ScriptOwner::App(req.app_id));
|
||||||
let resolver = PicloudModuleResolver::new(
|
let resolver = PicloudModuleResolver::new(
|
||||||
self.services.modules.clone(),
|
self.services.modules.clone(),
|
||||||
cx.clone(),
|
cx.clone(),
|
||||||
self.module_cache.clone(),
|
self.module_cache.clone(),
|
||||||
effective_limits.module_import_depth_max,
|
effective_limits.module_import_depth_max,
|
||||||
|
default_origin,
|
||||||
);
|
);
|
||||||
engine.set_module_resolver(resolver);
|
engine.set_module_resolver(resolver);
|
||||||
let self_engine = self.self_arc();
|
let self_engine = self.self_arc();
|
||||||
@@ -330,12 +358,13 @@ impl Engine {
|
|||||||
|m| m.into_inner().unwrap_or_default(),
|
|m| m.into_inner().unwrap_or_default(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let (status_code, headers, body) = parse_response(value)?;
|
let (status_code, headers, body, body_base64) = parse_response(value)?;
|
||||||
|
|
||||||
Ok(ExecResponse {
|
Ok(ExecResponse {
|
||||||
status_code,
|
status_code,
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
|
body_base64,
|
||||||
logs,
|
logs,
|
||||||
stats: ExecStats {
|
stats: ExecStats {
|
||||||
duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
|
duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
|
||||||
@@ -482,6 +511,14 @@ fn build_ctx_map(req: &ExecRequest) -> Map {
|
|||||||
"invocation_type".into(),
|
"invocation_type".into(),
|
||||||
invocation_type_str(req.invocation_type).into(),
|
invocation_type_str(req.invocation_type).into(),
|
||||||
);
|
);
|
||||||
|
// Read-only depth of this call in a trigger/invoke chain: 0 for direct
|
||||||
|
// ingress, +1 per synchronous re-entry (`invoke`) or dispatched handler.
|
||||||
|
// Exposed so a script can observe where it sits in the chain (the same value
|
||||||
|
// the platform bounds via `trigger_depth_max`).
|
||||||
|
ctx.insert(
|
||||||
|
"trigger_depth".into(),
|
||||||
|
(i64::from(req.trigger_depth)).into(),
|
||||||
|
);
|
||||||
|
|
||||||
let mut request = Map::new();
|
let mut request = Map::new();
|
||||||
request.insert("path".into(), req.path.clone().into());
|
request.insert("path".into(), req.path.clone().into());
|
||||||
@@ -495,6 +532,20 @@ fn build_ctx_map(req: &ExecRequest) -> Map {
|
|||||||
|
|
||||||
request.insert("body".into(), json_to_dynamic(req.body.clone()));
|
request.insert("body".into(), json_to_dynamic(req.body.clone()));
|
||||||
|
|
||||||
|
// F-026 convenience: the request Content-Type (also present in `headers`).
|
||||||
|
// Lets a script branch on how to read `body` — parsed JSON for a JSON
|
||||||
|
// request, a raw string for `text/*`, or a base64 string for other binary
|
||||||
|
// types — without a case-insensitive header lookup. `()` when absent.
|
||||||
|
let content_type = req
|
||||||
|
.headers
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
|
||||||
|
.map(|(_, v)| v.clone());
|
||||||
|
request.insert(
|
||||||
|
"content_type".into(),
|
||||||
|
content_type.map_or(Dynamic::UNIT, Into::into),
|
||||||
|
);
|
||||||
|
|
||||||
// SDK 1.1 additions — route-captured params, query string, prefix
|
// SDK 1.1 additions — route-captured params, query string, prefix
|
||||||
// tail. Empty when not applicable so scripts can always read them.
|
// tail. Empty when not applicable so scripts can always read them.
|
||||||
let mut params = Map::new();
|
let mut params = Map::new();
|
||||||
@@ -732,7 +783,9 @@ fn invocation_type_str(it: InvocationType) -> &'static str {
|
|||||||
// Response parsing
|
// Response parsing
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
fn parse_response(value: Dynamic) -> Result<(u16, BTreeMap<String, String>, Json), ExecError> {
|
type ParsedResponse = (u16, BTreeMap<String, String>, Json, Option<String>);
|
||||||
|
|
||||||
|
fn parse_response(value: Dynamic) -> Result<ParsedResponse, ExecError> {
|
||||||
// Convention: a Map with a `statusCode` field is the structured shape.
|
// Convention: a Map with a `statusCode` field is the structured shape.
|
||||||
// Anything else is treated as a 200 response with the value as body.
|
// Anything else is treated as a 200 response with the value as body.
|
||||||
if value.is_map() {
|
if value.is_map() {
|
||||||
@@ -742,10 +795,14 @@ fn parse_response(value: Dynamic) -> Result<(u16, BTreeMap<String, String>, Json
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok((200, BTreeMap::new(), dynamic_to_json(&value)))
|
// The response body is otherwise an uncapped Rhai→JSON exit — bound it so a
|
||||||
|
// cheaply-aliased huge value can't OOM the node on materialization.
|
||||||
|
let body = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)
|
||||||
|
.map_err(|e| ExecError::InvalidResponse(e.to_string()))?;
|
||||||
|
Ok((200, BTreeMap::new(), body, None))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_structured_response(map: Map) -> Result<(u16, BTreeMap<String, String>, Json), ExecError> {
|
fn parse_structured_response(map: Map) -> Result<ParsedResponse, ExecError> {
|
||||||
let status_dyn = map
|
let status_dyn = map
|
||||||
.get("statusCode")
|
.get("statusCode")
|
||||||
.ok_or_else(|| ExecError::InvalidResponse("missing statusCode".into()))?;
|
.ok_or_else(|| ExecError::InvalidResponse("missing statusCode".into()))?;
|
||||||
@@ -764,9 +821,29 @@ fn parse_structured_response(map: Map) -> Result<(u16, BTreeMap<String, String>,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = map.get("body").map_or(Json::Null, dynamic_to_json);
|
// F-026: a `body_base64` string field returns raw bytes with the script-set
|
||||||
|
// `Content-Type`, instead of JSON-encoding `body`. When set, `body` is
|
||||||
|
// ignored (the bytes win) so an author can't accidentally send both.
|
||||||
|
let body_base64 = match map.get("body_base64") {
|
||||||
|
Some(b) => Some(
|
||||||
|
b.clone()
|
||||||
|
.into_string()
|
||||||
|
.map_err(|_| ExecError::InvalidResponse("body_base64 must be a string".into()))?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
Ok((status_code, headers, body))
|
let body = if body_base64.is_some() {
|
||||||
|
Json::Null
|
||||||
|
} else {
|
||||||
|
match map.get("body") {
|
||||||
|
Some(b) => dynamic_to_json_capped(b, MAX_JSON_MATERIALIZE_BYTES)
|
||||||
|
.map_err(|e| ExecError::InvalidResponse(e.to_string()))?,
|
||||||
|
None => Json::Null,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((status_code, headers, body, body_base64))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -19,5 +19,6 @@ pub use module_resolver::{
|
|||||||
};
|
};
|
||||||
pub use sandbox::Limits;
|
pub use sandbox::Limits;
|
||||||
pub use types::{
|
pub use types::{
|
||||||
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
|
build_execution_log, ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry,
|
||||||
|
LogLevel,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,32 +1,42 @@
|
|||||||
//! `PicloudModuleResolver` — the v1.1.3 per-app Rhai module resolver.
|
//! `PicloudModuleResolver` — the Rhai module resolver (v1.1.3; made
|
||||||
|
//! origin-aware / lexical in v1.2 Phase 4b).
|
||||||
//!
|
//!
|
||||||
//! Replaces `DummyModuleResolver` in `Engine::build_engine`. Constructed
|
//! Replaces `DummyModuleResolver` in `Engine::build_engine`. Constructed
|
||||||
//! fresh per `Engine::execute` call: holds an `Arc<SdkCallCx>` so every
|
//! fresh per `Engine::execute` call.
|
||||||
//! `import "<name>"` request resolves against the calling app
|
//!
|
||||||
//! (`cx.app_id`). The script-side `name` argument carries no `app_id`
|
//! **Lexical resolution (§5.5).** An `import "<name>"` resolves against the
|
||||||
//! — that's the load-bearing cross-app isolation property.
|
//! module set visible at the **importing script's own defining node**,
|
||||||
|
//! walking up its group chain — not the inheriting app's effective view.
|
||||||
|
//! The importing node is read from Rhai's `_source` (the importing AST's
|
||||||
|
//! source tag, which the resolver sets to each module's owner) and falls
|
||||||
|
//! back to `default_origin` (the entry script's defining node) for the
|
||||||
|
//! top-level AST. So an inherited group script's imports seal to the group
|
||||||
|
//! (a leaf can't shadow them — the trust boundary), while every SDK call
|
||||||
|
//! *inside* a module still scopes to the inheriting app via `cx.app_id`.
|
||||||
//!
|
//!
|
||||||
//! Three runtime invariants are enforced:
|
//! Three runtime invariants are enforced:
|
||||||
//!
|
//!
|
||||||
//! 1. **Cross-app isolation** — `ModuleSource::lookup` is called with
|
//! 1. **Lexical isolation** — `ModuleSource::resolve` is keyed by the
|
||||||
//! `&cx`; the Postgres impl scopes by `cx.app_id` (never by a
|
//! importing node's owner (a trusted dispatch-derived value), never a
|
||||||
//! script-passed argument).
|
//! script-passed argument.
|
||||||
//! 2. **Cycle detection** — an in-progress-imports stack rejects
|
//! 2. **Cycle detection** — an in-progress-imports stack rejects
|
||||||
//! `A → B → A` with `ErrorInModule(... circular import detected ...)`.
|
//! `A → B → A` with `ErrorInModule(... circular import detected ...)`.
|
||||||
//! 3. **Depth limit** — guards against deep but acyclic chains
|
//! 3. **Depth limit** — guards against deep but acyclic chains
|
||||||
//! (default 8, override via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`).
|
//! (default 8, override via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`).
|
||||||
//!
|
//!
|
||||||
//! Compiled modules are cached per `(app_id, name)` and invalidated by
|
//! Compiled modules are cached by resolved `ScriptId` (a compiled module
|
||||||
//! `updated_at` change — no explicit pub/sub. The cache is owned by
|
//! is lexically self-contained) and invalidated by `updated_at` change —
|
||||||
//! `Engine` and shared across calls; only the resolver state (stack,
|
//! no explicit pub/sub. The cache is owned by `Engine` and shared across
|
||||||
//! depth) is per-call.
|
//! calls; only the resolver state (stack, depth) is per-call.
|
||||||
|
|
||||||
use std::num::NonZeroUsize;
|
use std::num::NonZeroUsize;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use lru::LruCache;
|
use lru::LruCache;
|
||||||
use picloud_shared::{AppId, ModuleSource, ModuleSourceError, SdkCallCx, ValidatedScript};
|
use picloud_shared::{
|
||||||
|
ModuleSource, ModuleSourceError, ScriptId, ScriptOwner, SdkCallCx, ValidatedScript,
|
||||||
|
};
|
||||||
use rhai::module_resolvers::ModuleResolver;
|
use rhai::module_resolvers::ModuleResolver;
|
||||||
use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
|
use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
|
||||||
|
|
||||||
@@ -35,10 +45,38 @@ use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
|
|||||||
/// `sync` feature that the workspace pins.
|
/// `sync` feature that the workspace pins.
|
||||||
type SharedRhaiModule = Shared<Module>;
|
type SharedRhaiModule = Shared<Module>;
|
||||||
|
|
||||||
/// Cache key: `(app_id, module name)`. v1.1.3 enforces module names as
|
/// Cache key: the resolved module's `ScriptId` (Phase 4b). A compiled
|
||||||
/// a conservative identifier shape (migration 0015 `scripts_module_name_shape`
|
/// module is lexically self-contained (its nested imports resolve from
|
||||||
/// CHECK) so the `String` here is bounded by ~64 bytes.
|
/// its own defining node, baked in at compile time), so the *body* is a
|
||||||
pub type ModuleCacheKey = (AppId, String);
|
/// pure function of `(script_id, updated_at)` — independent of which
|
||||||
|
/// script imported it. Keying by id also dedupes a shared group module
|
||||||
|
/// across every inheriting app, and keeps cross-app modules of the same
|
||||||
|
/// name distinct (distinct ids).
|
||||||
|
pub type ModuleCacheKey = ScriptId;
|
||||||
|
|
||||||
|
/// Encode a defining node as an AST `source` tag (`app:<uuid>` /
|
||||||
|
/// `group:<uuid>`). Set on a resolved module's AST so its own nested
|
||||||
|
/// `import`s carry *its* node as Rhai's `_source` — the lexical chaining
|
||||||
|
/// that seals each module's imports to where it was authored (§5.5).
|
||||||
|
fn encode_origin(owner: ScriptOwner) -> String {
|
||||||
|
match owner {
|
||||||
|
ScriptOwner::App(a) => format!("app:{a}"),
|
||||||
|
ScriptOwner::Group(g) => format!("group:{g}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverse of [`encode_origin`]. `None` for an unrecognised tag (e.g. a
|
||||||
|
/// source set by something other than this resolver) — the caller then
|
||||||
|
/// falls back to the resolver's `default_origin`.
|
||||||
|
fn decode_origin(s: &str) -> Option<ScriptOwner> {
|
||||||
|
let (kind, id) = s.split_once(':')?;
|
||||||
|
let uuid = uuid::Uuid::parse_str(id).ok()?;
|
||||||
|
match kind {
|
||||||
|
"app" => Some(ScriptOwner::App(uuid.into())),
|
||||||
|
"group" => Some(ScriptOwner::Group(uuid.into())),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cache value: the freshness comparator + the compiled module Rhai
|
/// Cache value: the freshness comparator + the compiled module Rhai
|
||||||
/// hands to importing scripts. Cloning the `Shared<Module>` is an Arc bump.
|
/// hands to importing scripts. Cloning the `Shared<Module>` is an Arc bump.
|
||||||
@@ -65,17 +103,19 @@ pub fn new_module_cache(capacity: usize) -> Arc<ModuleCache> {
|
|||||||
|
|
||||||
/// The v1.1.3 module resolver. One per `Engine::execute` call.
|
/// The v1.1.3 module resolver. One per `Engine::execute` call.
|
||||||
pub struct PicloudModuleResolver {
|
pub struct PicloudModuleResolver {
|
||||||
/// Backend the resolver consults for `(app_id, name)`. The bridge
|
/// Backend the resolver consults to resolve a module name against an
|
||||||
/// runs Rhai's sync `resolve()` and the async `lookup()` together
|
/// origin node's chain. The bridge runs Rhai's sync `resolve()` and the
|
||||||
/// via `tokio::runtime::Handle::block_on(...)` — safe because
|
/// async `ModuleSource::resolve()` together via
|
||||||
|
/// `tokio::runtime::Handle::block_on(...)` — safe because
|
||||||
/// `LocalExecutorClient` runs `Engine::execute` inside
|
/// `LocalExecutorClient` runs `Engine::execute` inside
|
||||||
/// `spawn_blocking`, which puts us on a Tokio blocking thread
|
/// `spawn_blocking`, which puts us on a Tokio blocking thread
|
||||||
/// that still carries a `Handle`.
|
/// that still carries a `Handle`.
|
||||||
source: Arc<dyn ModuleSource>,
|
source: Arc<dyn ModuleSource>,
|
||||||
|
|
||||||
/// Calling context. `cx.app_id` is the cross-app isolation
|
/// Calling context. `cx.app_id` is the execution boundary every SDK
|
||||||
/// boundary; the resolver passes `&cx` to every `ModuleSource`
|
/// call inside a module scopes to; the resolver keeps it for diagnostic
|
||||||
/// call so the backend can scope its queries.
|
/// logging. (Module *resolution* is keyed by the importing node's owner,
|
||||||
|
/// not `cx.app_id` — see the module docs.)
|
||||||
cx: Arc<SdkCallCx>,
|
cx: Arc<SdkCallCx>,
|
||||||
|
|
||||||
/// Compiled-module cache. Shared across executions; invalidated
|
/// Compiled-module cache. Shared across executions; invalidated
|
||||||
@@ -96,6 +136,12 @@ pub struct PicloudModuleResolver {
|
|||||||
/// via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`. Read from `Limits` at
|
/// via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`. Read from `Limits` at
|
||||||
/// resolver construction.
|
/// resolver construction.
|
||||||
depth_limit: u32,
|
depth_limit: u32,
|
||||||
|
|
||||||
|
/// Lexical origin for a top-level `import` — the defining node of the
|
||||||
|
/// script being executed (Phase 4b, §5.5). Used when Rhai gives no
|
||||||
|
/// `_source` (the entry AST). Nested imports inside a resolved module
|
||||||
|
/// override this via the module AST's source (C3).
|
||||||
|
default_origin: ScriptOwner,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PicloudModuleResolver {
|
impl PicloudModuleResolver {
|
||||||
@@ -105,6 +151,7 @@ impl PicloudModuleResolver {
|
|||||||
cx: Arc<SdkCallCx>,
|
cx: Arc<SdkCallCx>,
|
||||||
cache: Arc<ModuleCache>,
|
cache: Arc<ModuleCache>,
|
||||||
depth_limit: u32,
|
depth_limit: u32,
|
||||||
|
default_origin: ScriptOwner,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
source,
|
source,
|
||||||
@@ -113,6 +160,7 @@ impl PicloudModuleResolver {
|
|||||||
in_progress: Mutex::new(Vec::new()),
|
in_progress: Mutex::new(Vec::new()),
|
||||||
depth: Mutex::new(0),
|
depth: Mutex::new(0),
|
||||||
depth_limit,
|
depth_limit,
|
||||||
|
default_origin,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +272,7 @@ impl ModuleResolver for PicloudModuleResolver {
|
|||||||
fn resolve(
|
fn resolve(
|
||||||
&self,
|
&self,
|
||||||
engine: &RhaiEngine,
|
engine: &RhaiEngine,
|
||||||
_source: Option<&str>,
|
importer_source: Option<&str>,
|
||||||
path: &str,
|
path: &str,
|
||||||
pos: Position,
|
pos: Position,
|
||||||
) -> Result<SharedRhaiModule, Box<EvalAltResult>> {
|
) -> Result<SharedRhaiModule, Box<EvalAltResult>> {
|
||||||
@@ -319,17 +367,50 @@ impl ModuleResolver for PicloudModuleResolver {
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
|
// Lexical resolution (§5.5): resolve `path` against the *importing
|
||||||
tokio::task::block_in_place(|| handle.block_on(self.source.lookup(&self.cx, path)));
|
// node's* chain. Rhai gives `_source` = the importing AST's source
|
||||||
|
// tag — set to the module's defining node for a nested import, or
|
||||||
|
// unset (`None`) for the top-level entry script, where we fall back
|
||||||
|
// to `default_origin` (the executing script's node). An app-rooted
|
||||||
|
// walk reaches ancestor group modules; a group-rooted walk is sealed
|
||||||
|
// from app modules below it (the trust boundary).
|
||||||
|
let origin = importer_source
|
||||||
|
.and_then(decode_origin)
|
||||||
|
.unwrap_or(self.default_origin);
|
||||||
|
// §5.5 policy resolution: the source decides lexical (seal to `origin`)
|
||||||
|
// vs dynamic (an extension point resolves against the inheriting app,
|
||||||
|
// `cx.app_id`). The result is already the concrete module to bind, or a
|
||||||
|
// terminal NoProvider/NotFound.
|
||||||
|
let lookup_result: Result<picloud_shared::ModuleResolution, ModuleSourceError> =
|
||||||
|
tokio::task::block_in_place(|| {
|
||||||
|
handle.block_on(self.source.resolve_policy(origin, self.cx.app_id, path))
|
||||||
|
});
|
||||||
|
|
||||||
let module_row = match lookup_result {
|
let module_row = match lookup_result {
|
||||||
Ok(Some(m)) => m,
|
Ok(picloud_shared::ModuleResolution::Module(m)) => m,
|
||||||
Ok(None) => {
|
Ok(picloud_shared::ModuleResolution::NotFound) => {
|
||||||
return Err(Box::new(EvalAltResult::ErrorModuleNotFound(
|
return Err(Box::new(EvalAltResult::ErrorModuleNotFound(
|
||||||
path.to_string(),
|
path.to_string(),
|
||||||
pos,
|
pos,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
Ok(picloud_shared::ModuleResolution::NoProvider) => {
|
||||||
|
// §5.5: an extension point with no provider for this app is a
|
||||||
|
// hard failure (the app must supply the module or a default
|
||||||
|
// body must exist up-chain). Distinct, actionable message.
|
||||||
|
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||||
|
path.to_string(),
|
||||||
|
Box::new(EvalAltResult::ErrorRuntime(
|
||||||
|
format!(
|
||||||
|
"extension point {path:?} has no provider for this app — \
|
||||||
|
define a module named {path:?} or a default body up-chain"
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
pos,
|
||||||
|
)),
|
||||||
|
pos,
|
||||||
|
)));
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// v1.1.4 §10a: redact the backend error before it
|
// v1.1.4 §10a: redact the backend error before it
|
||||||
// reaches a script. In public-HTTP context (principal:
|
// reaches a script. In public-HTTP context (principal:
|
||||||
@@ -355,8 +436,10 @@ impl ModuleResolver for PicloudModuleResolver {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Cache lookup: hit only if both key matches AND updated_at
|
// Cache lookup: hit only if both key matches AND updated_at
|
||||||
// matches (cache is invalidated lazily on version change).
|
// matches (cache is invalidated lazily on version change). Keyed by
|
||||||
let cache_key = (self.cx.app_id, path.to_string());
|
// the resolved module's id (lexically self-contained — see
|
||||||
|
// `ModuleCacheKey`).
|
||||||
|
let cache_key = module_row.script_id;
|
||||||
{
|
{
|
||||||
let mut cache = self.cache.lock().expect("module cache lock poisoned");
|
let mut cache = self.cache.lock().expect("module cache lock poisoned");
|
||||||
if let Some(cached) = cache.get(&cache_key) {
|
if let Some(cached) = cache.get(&cache_key) {
|
||||||
@@ -389,7 +472,7 @@ impl ModuleResolver for PicloudModuleResolver {
|
|||||||
// already been gated at create-time (admin endpoint runs
|
// already been gated at create-time (admin endpoint runs
|
||||||
// `validate_module`), but we revalidate here to catch DB-direct
|
// `validate_module`), but we revalidate here to catch DB-direct
|
||||||
// inserts that bypass the API surface.
|
// inserts that bypass the API surface.
|
||||||
let ast = engine.compile(&module_row.source).map_err(|e| {
|
let mut ast = engine.compile(&module_row.source).map_err(|e| {
|
||||||
// Wrap as an ErrorRuntime to preserve the parse message
|
// Wrap as an ErrorRuntime to preserve the parse message
|
||||||
// text without trying to reconstruct rhai's internal
|
// text without trying to reconstruct rhai's internal
|
||||||
// ParseErrorType variant (which would require matching on
|
// ParseErrorType variant (which would require matching on
|
||||||
@@ -404,6 +487,15 @@ impl ModuleResolver for PicloudModuleResolver {
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Seal this module's own imports to its defining node (§5.5): tag
|
||||||
|
// the AST source with the resolved module's owner so that when
|
||||||
|
// `eval_ast_as_new` evaluates its nested `import`s, Rhai hands us
|
||||||
|
// that tag as `_source` and we resolve them lexically from *here*,
|
||||||
|
// not from the original importer. Fall back to the lookup origin
|
||||||
|
// for a corrupt owner row (never reached under the DB CHECK).
|
||||||
|
let module_owner = module_row.owner().unwrap_or(origin);
|
||||||
|
ast.set_source(encode_origin(module_owner));
|
||||||
|
|
||||||
if let Err(msg) = Self::check_module_shape(&ast, path) {
|
if let Err(msg) = Self::check_module_shape(&ast, path) {
|
||||||
return Err(Box::new(EvalAltResult::ErrorInModule(
|
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||||
path.to_string(),
|
path.to_string(),
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ where
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a Rhai runtime error from a message. Returns the boxed error
|
||||||
|
/// directly because every caller needs a `Box<EvalAltResult>` (Rhai's error
|
||||||
|
/// type) — the shared home for the pattern the SDK bridges (secrets, email,
|
||||||
|
/// users, …) previously each copied.
|
||||||
|
#[allow(clippy::unnecessary_box_returns)]
|
||||||
|
pub(crate) fn runtime_err(msg: &str) -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(msg.into(), rhai::Position::NONE).into()
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a `serde_json::Value` into a Rhai `Dynamic` suitable for
|
/// Convert a `serde_json::Value` into a Rhai `Dynamic` suitable for
|
||||||
/// pushing into a script's scope. Numbers prefer the narrowest type
|
/// pushing into a script's scope. Numbers prefer the narrowest type
|
||||||
/// (`i64` over `f64`); anything that can't round-trip falls back to a
|
/// (`i64` over `f64`); anything that can't round-trip falls back to a
|
||||||
@@ -76,35 +85,173 @@ pub fn json_to_dynamic(value: Json) -> Dynamic {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert a Rhai `Dynamic` back to a `serde_json::Value`. Custom Rhai
|
/// Hard ceiling on the byte size of a single Rhai→JSON materialization.
|
||||||
/// types (timestamps, user-registered modules) fall back to their
|
///
|
||||||
/// `Display` form so they appear as strings in JSON output rather than
|
/// The per-element Rhai sandbox caps (`max_string_size` 64 KiB,
|
||||||
/// failing the response build.
|
/// `max_array_size`/`max_map_size` 10 000) do NOT bound the *materialized*
|
||||||
|
/// total: Rhai shares strings/arrays by `Rc`, so a 10 000-element array of one
|
||||||
|
/// aliased 64 KiB string is cheap to build (~10 000 ops) yet dealiases to
|
||||||
|
/// ~640 MiB of distinct JSON. Without a ceiling a handful of anonymous requests
|
||||||
|
/// could OOM the node. `dynamic_to_json_capped` charges a running byte budget
|
||||||
|
/// and bails as soon as it is exceeded, so the transient allocation is bounded
|
||||||
|
/// to roughly this ceiling regardless of aliasing. This is far above any
|
||||||
|
/// legitimate single value (the KV/docs value caps are 256 KiB); it is a
|
||||||
|
/// last-resort anti-OOM rail, not a business limit.
|
||||||
|
pub const MAX_JSON_MATERIALIZE_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// A Rhai value was too large to materialize into JSON within its byte budget.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct JsonSizeError {
|
||||||
|
pub limit: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for JsonSizeError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"value too large to serialize to JSON (exceeds the {}-byte limit)",
|
||||||
|
self.limit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for JsonSizeError {}
|
||||||
|
|
||||||
|
impl From<JsonSizeError> for Box<EvalAltResult> {
|
||||||
|
fn from(e: JsonSizeError) -> Self {
|
||||||
|
runtime_err(&e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a Rhai `Dynamic` back to a `serde_json::Value`, **infallibly** and
|
||||||
|
/// **bounded** by [`MAX_JSON_MATERIALIZE_BYTES`]. A value that would exceed the
|
||||||
|
/// ceiling collapses to a short marker string rather than OOMing — safe for
|
||||||
|
/// best-effort paths (logging) where a hard error is undesirable. Paths where
|
||||||
|
/// silently substituting a marker would be wrong (writes, the HTTP response
|
||||||
|
/// body, `invoke` args) must use [`dynamic_to_json_capped`] and surface the
|
||||||
|
/// error instead.
|
||||||
|
///
|
||||||
|
/// Custom Rhai types (timestamps, user-registered modules) fall back to their
|
||||||
|
/// `Display` form so they appear as strings rather than failing the build.
|
||||||
|
#[must_use]
|
||||||
pub fn dynamic_to_json(value: &Dynamic) -> Json {
|
pub fn dynamic_to_json(value: &Dynamic) -> Json {
|
||||||
|
dynamic_to_json_capped(value, MAX_JSON_MATERIALIZE_BYTES)
|
||||||
|
.unwrap_or_else(|_| Json::String("<value too large>".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded `Dynamic`→JSON conversion: materializes `value`, charging a running
|
||||||
|
/// byte budget starting at `max_bytes`, and returns [`JsonSizeError`] the moment
|
||||||
|
/// the budget is exceeded — so it never builds the full oversized tree.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`JsonSizeError`] if the materialized JSON would exceed `max_bytes`.
|
||||||
|
pub fn dynamic_to_json_capped(value: &Dynamic, max_bytes: usize) -> Result<Json, JsonSizeError> {
|
||||||
|
let mut remaining = max_bytes;
|
||||||
|
to_json_bounded(value, &mut remaining, max_bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deduct `n` bytes from `remaining`, or fail if the budget is exhausted.
|
||||||
|
fn charge(remaining: &mut usize, n: usize, limit: usize) -> Result<(), JsonSizeError> {
|
||||||
|
match remaining.checked_sub(n) {
|
||||||
|
Some(left) => {
|
||||||
|
*remaining = left;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(JsonSizeError { limit }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_json_bounded(
|
||||||
|
value: &Dynamic,
|
||||||
|
remaining: &mut usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Json, JsonSizeError> {
|
||||||
if value.is_unit() {
|
if value.is_unit() {
|
||||||
return Json::Null;
|
charge(remaining, 4, limit)?;
|
||||||
|
return Ok(Json::Null);
|
||||||
}
|
}
|
||||||
if let Ok(b) = value.as_bool() {
|
if let Ok(b) = value.as_bool() {
|
||||||
return Json::Bool(b);
|
charge(remaining, 5, limit)?;
|
||||||
|
return Ok(Json::Bool(b));
|
||||||
}
|
}
|
||||||
if let Ok(i) = value.as_int() {
|
if let Ok(i) = value.as_int() {
|
||||||
return Json::Number(i.into());
|
charge(remaining, 8, limit)?;
|
||||||
|
return Ok(Json::Number(i.into()));
|
||||||
}
|
}
|
||||||
if let Ok(f) = value.as_float() {
|
if let Ok(f) = value.as_float() {
|
||||||
return serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number);
|
charge(remaining, 8, limit)?;
|
||||||
|
return Ok(serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number));
|
||||||
}
|
}
|
||||||
if value.is_string() {
|
if value.is_string() {
|
||||||
return Json::String(value.clone().into_string().unwrap_or_default());
|
let s = value.clone().into_string().unwrap_or_default();
|
||||||
|
charge(remaining, s.len() + 2, limit)?;
|
||||||
|
return Ok(Json::String(s));
|
||||||
}
|
}
|
||||||
if let Some(arr) = value.clone().try_cast::<rhai::Array>() {
|
if let Some(arr) = value.clone().try_cast::<rhai::Array>() {
|
||||||
return Json::Array(arr.iter().map(dynamic_to_json).collect());
|
charge(remaining, 2, limit)?; // "[]"
|
||||||
|
let mut out = Vec::with_capacity(arr.len().min(1024));
|
||||||
|
for v in &arr {
|
||||||
|
charge(remaining, 1, limit)?; // ","
|
||||||
|
out.push(to_json_bounded(v, remaining, limit)?);
|
||||||
|
}
|
||||||
|
return Ok(Json::Array(out));
|
||||||
}
|
}
|
||||||
if let Some(map) = value.clone().try_cast::<Map>() {
|
if let Some(map) = value.clone().try_cast::<Map>() {
|
||||||
|
charge(remaining, 2, limit)?; // "{}"
|
||||||
let mut out = serde_json::Map::new();
|
let mut out = serde_json::Map::new();
|
||||||
for (k, v) in map {
|
for (k, v) in map {
|
||||||
out.insert(k.to_string(), dynamic_to_json(&v));
|
charge(remaining, k.len() + 4, limit)?; // "key":,
|
||||||
|
out.insert(k.to_string(), to_json_bounded(&v, remaining, limit)?);
|
||||||
}
|
}
|
||||||
return Json::Object(out);
|
return Ok(Json::Object(out));
|
||||||
|
}
|
||||||
|
let s = value.to_string();
|
||||||
|
charge(remaining, s.len() + 2, limit)?;
|
||||||
|
Ok(Json::String(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rhai::{Array, Dynamic};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn small_value_round_trips_under_the_cap() {
|
||||||
|
let d = Dynamic::from("hello");
|
||||||
|
assert_eq!(
|
||||||
|
dynamic_to_json_capped(&d, MAX_JSON_MATERIALIZE_BYTES).unwrap(),
|
||||||
|
Json::String("hello".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aliased_large_array_is_rejected_not_materialized() {
|
||||||
|
// The OOM regression: one 64 KiB string aliased 10 000×. Cheap to build
|
||||||
|
// (Rc-shared), ~640 MiB if dealiased. The bounded converter must bail on
|
||||||
|
// the byte budget instead of allocating the full tree.
|
||||||
|
let big = "x".repeat(64 * 1024);
|
||||||
|
let mut arr = Array::new();
|
||||||
|
for _ in 0..10_000 {
|
||||||
|
arr.push(Dynamic::from(big.clone())); // ImmutableString: shared, cheap
|
||||||
|
}
|
||||||
|
let d = Dynamic::from(arr);
|
||||||
|
// A 1 MiB budget is far below the ~640 MiB dealiased size → must error.
|
||||||
|
let err = dynamic_to_json_capped(&d, 1024 * 1024).unwrap_err();
|
||||||
|
assert_eq!(err.limit, 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn infallible_wrapper_yields_marker_instead_of_oom() {
|
||||||
|
// ~300 × 64 KiB aliased ≈ 19 MiB dealiased, just over the 16 MiB default
|
||||||
|
// ceiling — enough to trip the infallible wrapper's marker fallback
|
||||||
|
// without paying to build a pathologically huge test array.
|
||||||
|
let big = "x".repeat(64 * 1024);
|
||||||
|
let mut arr = Array::new();
|
||||||
|
for _ in 0..300 {
|
||||||
|
arr.push(Dynamic::from(big.clone()));
|
||||||
|
}
|
||||||
|
// The infallible path must NOT OOM — it collapses to a marker string.
|
||||||
|
let out = dynamic_to_json(&Dynamic::from(arr));
|
||||||
|
assert_eq!(out, Json::String("<value too large>".into()));
|
||||||
}
|
}
|
||||||
Json::String(value.to_string())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,14 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use picloud_shared::{DocId, DocRow, DocsService, SdkCallCx, Services};
|
use picloud_shared::{DocId, DocRow, DocsService, GroupDocsService, SdkCallCx, Services};
|
||||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::bridge::{block_on, dynamic_to_json, json_to_dynamic};
|
use super::bridge::{
|
||||||
|
block_on, dynamic_to_json_capped, json_to_dynamic, MAX_JSON_MATERIALIZE_BYTES,
|
||||||
|
};
|
||||||
|
use super::interceptor::InterceptorCtx;
|
||||||
|
|
||||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||||
/// plus an owned string).
|
/// plus an owned string).
|
||||||
@@ -36,15 +39,37 @@ pub struct DocsHandle {
|
|||||||
collection: String,
|
collection: String,
|
||||||
service: Arc<dyn DocsService>,
|
service: Arc<dyn DocsService>,
|
||||||
cx: Arc<SdkCallCx>,
|
cx: Arc<SdkCallCx>,
|
||||||
|
// §9.4: carried so `create`/`update`/`delete` run the before/after hook (M8).
|
||||||
|
ictx: InterceptorCtx,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
/// §11.6 shared-collection handle, returned by `docs::shared_collection(name)`.
|
||||||
|
/// A distinct Rhai type from `DocsHandle` so a script's choice of
|
||||||
|
/// private-vs-shared scope is explicit. Routes through `GroupDocsService`, which
|
||||||
|
/// resolves the owning group from `cx.app_id` (the script never names a group).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GroupDocsHandle {
|
||||||
|
collection: String,
|
||||||
|
service: Arc<dyn GroupDocsService>,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
// §9.4: carried so shared `create`/`update`/`delete` run the hook too (M8).
|
||||||
|
ictx: InterceptorCtx,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn register(
|
||||||
|
engine: &mut RhaiEngine,
|
||||||
|
services: &Services,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
ictx: InterceptorCtx,
|
||||||
|
) {
|
||||||
let docs_service = services.docs.clone();
|
let docs_service = services.docs.clone();
|
||||||
|
let group_docs_service = services.group_docs.clone();
|
||||||
|
|
||||||
let mut module = Module::new();
|
let mut module = Module::new();
|
||||||
{
|
{
|
||||||
let docs_service = docs_service.clone();
|
let docs_service = docs_service.clone();
|
||||||
let cx = cx.clone();
|
let cx = cx.clone();
|
||||||
|
let ictx = ictx.clone();
|
||||||
module.set_native_fn(
|
module.set_native_fn(
|
||||||
"collection",
|
"collection",
|
||||||
move |name: &str| -> Result<DocsHandle, Box<EvalAltResult>> {
|
move |name: &str| -> Result<DocsHandle, Box<EvalAltResult>> {
|
||||||
@@ -55,6 +80,26 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
collection: name.to_string(),
|
collection: name.to_string(),
|
||||||
service: docs_service.clone(),
|
service: docs_service.clone(),
|
||||||
cx: cx.clone(),
|
cx: cx.clone(),
|
||||||
|
ictx: ictx.clone(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let group_docs_service = group_docs_service.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let ictx = ictx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"shared_collection",
|
||||||
|
move |name: &str| -> Result<GroupDocsHandle, Box<EvalAltResult>> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("docs::shared_collection name must not be empty".into());
|
||||||
|
}
|
||||||
|
Ok(GroupDocsHandle {
|
||||||
|
collection: name.to_string(),
|
||||||
|
service: group_docs_service.clone(),
|
||||||
|
cx: cx.clone(),
|
||||||
|
ictx: ictx.clone(),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -70,18 +115,40 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
register_update(engine);
|
register_update(engine);
|
||||||
register_delete(engine);
|
register_delete(engine);
|
||||||
register_list(engine);
|
register_list(engine);
|
||||||
|
|
||||||
|
// Same method names on GroupDocsHandle — Rhai dispatches by receiver type.
|
||||||
|
engine.register_type_with_name::<GroupDocsHandle>("GroupDocsHandle");
|
||||||
|
|
||||||
|
register_group_create(engine);
|
||||||
|
register_group_get(engine);
|
||||||
|
register_group_find(engine);
|
||||||
|
register_group_find_one(engine);
|
||||||
|
register_group_update(engine);
|
||||||
|
register_group_delete(engine);
|
||||||
|
register_group_list(engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register_create(engine: &mut RhaiEngine) {
|
fn register_create(engine: &mut RhaiEngine) {
|
||||||
engine.register_fn(
|
engine.register_fn(
|
||||||
"create",
|
"create",
|
||||||
|handle: &mut DocsHandle, data: Map| -> Result<String, Box<EvalAltResult>> {
|
|handle: &mut DocsHandle, data: Map| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
let json = dynamic_to_json_capped(&Dynamic::from(data), MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
|
// §9.4 before-op interceptor (allow/deny + M4 data-transform).
|
||||||
|
let write_val = run_before(handle, "create", "", Some(&json))?.unwrap_or(json);
|
||||||
|
let written = write_val.clone();
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let json = dynamic_to_json(&Dynamic::from(data));
|
|
||||||
let id = block_on("docs", async move {
|
let id = block_on("docs", async move {
|
||||||
h.service.create(&h.cx, &h.collection, json).await
|
h.service.create(&h.cx, &h.collection, write_val).await
|
||||||
})?;
|
})?;
|
||||||
Ok(id.to_string())
|
let id_str = id.to_string();
|
||||||
|
run_after(
|
||||||
|
handle,
|
||||||
|
"create",
|
||||||
|
"",
|
||||||
|
Some(&written),
|
||||||
|
serde_json::Value::String(id_str.clone()),
|
||||||
|
)?;
|
||||||
|
Ok(id_str)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -105,7 +172,7 @@ fn register_find(engine: &mut RhaiEngine) {
|
|||||||
"find",
|
"find",
|
||||||
|handle: &mut DocsHandle, filter: Map| -> Result<Array, Box<EvalAltResult>> {
|
|handle: &mut DocsHandle, filter: Map| -> Result<Array, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let json = dynamic_to_json(&Dynamic::from(filter));
|
let json = dynamic_to_json_capped(&Dynamic::from(filter), MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
let rows = block_on("docs", async move {
|
let rows = block_on("docs", async move {
|
||||||
h.service.find(&h.cx, &h.collection, json).await
|
h.service.find(&h.cx, &h.collection, json).await
|
||||||
})?;
|
})?;
|
||||||
@@ -122,7 +189,7 @@ fn register_find_one(engine: &mut RhaiEngine) {
|
|||||||
"find_one",
|
"find_one",
|
||||||
|handle: &mut DocsHandle, filter: Map| -> Result<Dynamic, Box<EvalAltResult>> {
|
|handle: &mut DocsHandle, filter: Map| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let json = dynamic_to_json(&Dynamic::from(filter));
|
let json = dynamic_to_json_capped(&Dynamic::from(filter), MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
let row = block_on("docs", async move {
|
let row = block_on("docs", async move {
|
||||||
h.service.find_one(&h.cx, &h.collection, json).await
|
h.service.find_one(&h.cx, &h.collection, json).await
|
||||||
})?;
|
})?;
|
||||||
@@ -135,14 +202,23 @@ fn register_update(engine: &mut RhaiEngine) {
|
|||||||
engine.register_fn(
|
engine.register_fn(
|
||||||
"update",
|
"update",
|
||||||
|handle: &mut DocsHandle, id: &str, data: Map| -> Result<(), Box<EvalAltResult>> {
|
|handle: &mut DocsHandle, id: &str, data: Map| -> Result<(), Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
|
||||||
let parsed_id = parse_doc_id(id)?;
|
let parsed_id = parse_doc_id(id)?;
|
||||||
let json = dynamic_to_json(&Dynamic::from(data));
|
let json = dynamic_to_json_capped(&Dynamic::from(data), MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
|
let write_val = run_before(handle, "update", id, Some(&json))?.unwrap_or(json);
|
||||||
|
let written = write_val.clone();
|
||||||
|
let h = handle.clone();
|
||||||
block_on("docs", async move {
|
block_on("docs", async move {
|
||||||
h.service
|
h.service
|
||||||
.update(&h.cx, &h.collection, parsed_id, json)
|
.update(&h.cx, &h.collection, parsed_id, write_val)
|
||||||
.await
|
.await
|
||||||
})
|
})?;
|
||||||
|
run_after(
|
||||||
|
handle,
|
||||||
|
"update",
|
||||||
|
id,
|
||||||
|
Some(&written),
|
||||||
|
serde_json::Value::Null,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -151,15 +227,62 @@ fn register_delete(engine: &mut RhaiEngine) {
|
|||||||
engine.register_fn(
|
engine.register_fn(
|
||||||
"delete",
|
"delete",
|
||||||
|handle: &mut DocsHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
|handle: &mut DocsHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
|
||||||
let parsed_id = parse_doc_id(id)?;
|
let parsed_id = parse_doc_id(id)?;
|
||||||
block_on("docs", async move {
|
// Delete carries no value, so the before-hook never transforms.
|
||||||
|
run_before(handle, "delete", id, None)?;
|
||||||
|
let h = handle.clone();
|
||||||
|
let was_present = block_on("docs", async move {
|
||||||
h.service.delete(&h.cx, &h.collection, parsed_id).await
|
h.service.delete(&h.cx, &h.collection, parsed_id).await
|
||||||
})
|
})?;
|
||||||
|
run_after(
|
||||||
|
handle,
|
||||||
|
"delete",
|
||||||
|
id,
|
||||||
|
None,
|
||||||
|
serde_json::Value::Bool(was_present),
|
||||||
|
)?;
|
||||||
|
Ok(was_present)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §9.4: run the before-op interceptor for a per-app docs write.
|
||||||
|
fn run_before(
|
||||||
|
handle: &DocsHandle,
|
||||||
|
op: &'static str,
|
||||||
|
key: &str,
|
||||||
|
value: Option<&serde_json::Value>,
|
||||||
|
) -> Result<Option<serde_json::Value>, Box<EvalAltResult>> {
|
||||||
|
super::interceptor::run_before(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"docs",
|
||||||
|
op,
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_after(
|
||||||
|
handle: &DocsHandle,
|
||||||
|
op: &'static str,
|
||||||
|
key: &str,
|
||||||
|
value: Option<&serde_json::Value>,
|
||||||
|
result: serde_json::Value,
|
||||||
|
) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
super::interceptor::run_after(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"docs",
|
||||||
|
op,
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn register_list(engine: &mut RhaiEngine) {
|
fn register_list(engine: &mut RhaiEngine) {
|
||||||
// Zero-arg form: full page from the start.
|
// Zero-arg form: full page from the start.
|
||||||
engine.register_fn(
|
engine.register_fn(
|
||||||
@@ -218,6 +341,219 @@ fn list_call(
|
|||||||
Ok(m)
|
Ok(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- GroupDocsHandle methods (§11.6 shared docs collections) ---------------
|
||||||
|
|
||||||
|
fn register_group_create(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"create",
|
||||||
|
|handle: &mut GroupDocsHandle, data: Map| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
let json = dynamic_to_json_capped(&Dynamic::from(data), MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
|
let write_val = group_run_before(handle, "create", "", Some(&json))?.unwrap_or(json);
|
||||||
|
let written = write_val.clone();
|
||||||
|
let h = handle.clone();
|
||||||
|
let id = block_on("docs", async move {
|
||||||
|
h.service.create(&h.cx, &h.collection, write_val).await
|
||||||
|
})?;
|
||||||
|
let id_str = id.to_string();
|
||||||
|
group_run_after(
|
||||||
|
handle,
|
||||||
|
"create",
|
||||||
|
"",
|
||||||
|
Some(&written),
|
||||||
|
serde_json::Value::String(id_str.clone()),
|
||||||
|
)?;
|
||||||
|
Ok(id_str)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_get(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"get",
|
||||||
|
|handle: &mut GroupDocsHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let parsed_id = parse_doc_id(id)?;
|
||||||
|
let row = block_on("docs", async move {
|
||||||
|
h.service.get(&h.cx, &h.collection, parsed_id).await
|
||||||
|
})?;
|
||||||
|
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_find(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"find",
|
||||||
|
|handle: &mut GroupDocsHandle, filter: Map| -> Result<Array, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let json = dynamic_to_json_capped(&Dynamic::from(filter), MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
|
let rows = block_on("docs", async move {
|
||||||
|
h.service.find(&h.cx, &h.collection, json).await
|
||||||
|
})?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|d| Dynamic::from(doc_to_map(d)))
|
||||||
|
.collect::<Vec<Dynamic>>())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_find_one(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"find_one",
|
||||||
|
|handle: &mut GroupDocsHandle, filter: Map| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let json = dynamic_to_json_capped(&Dynamic::from(filter), MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
|
let row = block_on("docs", async move {
|
||||||
|
h.service.find_one(&h.cx, &h.collection, json).await
|
||||||
|
})?;
|
||||||
|
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_update(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"update",
|
||||||
|
|handle: &mut GroupDocsHandle, id: &str, data: Map| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let parsed_id = parse_doc_id(id)?;
|
||||||
|
let json = dynamic_to_json_capped(&Dynamic::from(data), MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
|
let write_val = group_run_before(handle, "update", id, Some(&json))?.unwrap_or(json);
|
||||||
|
let written = write_val.clone();
|
||||||
|
let h = handle.clone();
|
||||||
|
block_on("docs", async move {
|
||||||
|
h.service
|
||||||
|
.update(&h.cx, &h.collection, parsed_id, write_val)
|
||||||
|
.await
|
||||||
|
})?;
|
||||||
|
group_run_after(
|
||||||
|
handle,
|
||||||
|
"update",
|
||||||
|
id,
|
||||||
|
Some(&written),
|
||||||
|
serde_json::Value::Null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_delete(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"delete",
|
||||||
|
|handle: &mut GroupDocsHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
let parsed_id = parse_doc_id(id)?;
|
||||||
|
group_run_before(handle, "delete", id, None)?;
|
||||||
|
let h = handle.clone();
|
||||||
|
let was_present = block_on("docs", async move {
|
||||||
|
h.service.delete(&h.cx, &h.collection, parsed_id).await
|
||||||
|
})?;
|
||||||
|
group_run_after(
|
||||||
|
handle,
|
||||||
|
"delete",
|
||||||
|
id,
|
||||||
|
None,
|
||||||
|
serde_json::Value::Bool(was_present),
|
||||||
|
)?;
|
||||||
|
Ok(was_present)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §9.4: before/after hooks for a shared (group) docs write — mirror the per-app
|
||||||
|
/// helpers but over `GroupDocsHandle`.
|
||||||
|
fn group_run_before(
|
||||||
|
handle: &GroupDocsHandle,
|
||||||
|
op: &'static str,
|
||||||
|
key: &str,
|
||||||
|
value: Option<&serde_json::Value>,
|
||||||
|
) -> Result<Option<serde_json::Value>, Box<EvalAltResult>> {
|
||||||
|
super::interceptor::run_before(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"docs",
|
||||||
|
op,
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_run_after(
|
||||||
|
handle: &GroupDocsHandle,
|
||||||
|
op: &'static str,
|
||||||
|
key: &str,
|
||||||
|
value: Option<&serde_json::Value>,
|
||||||
|
result: serde_json::Value,
|
||||||
|
) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
super::interceptor::run_after(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"docs",
|
||||||
|
op,
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_list(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupDocsHandle| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
group_list_call(handle, None, 0)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupDocsHandle, args: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let cursor = match args.get("cursor") {
|
||||||
|
Some(d) if !d.is_unit() => {
|
||||||
|
Some(d.clone().into_string().map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
"docs::list: 'cursor' must be a string or ()".into()
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let limit = match args.get("limit") {
|
||||||
|
Some(d) if !d.is_unit() => {
|
||||||
|
let n = d.as_int().map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
"docs::list: 'limit' must be an integer".into()
|
||||||
|
})?;
|
||||||
|
u32::try_from(n.max(0)).unwrap_or(0)
|
||||||
|
}
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
group_list_call(handle, cursor, limit)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_list_call(
|
||||||
|
handle: &GroupDocsHandle,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let page = block_on("docs", async move {
|
||||||
|
h.service
|
||||||
|
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||||
|
.await
|
||||||
|
})?;
|
||||||
|
let mut m = Map::new();
|
||||||
|
let docs: Array = page
|
||||||
|
.docs
|
||||||
|
.iter()
|
||||||
|
.map(|d| Dynamic::from(doc_to_map(d)))
|
||||||
|
.collect();
|
||||||
|
m.insert("docs".into(), docs.into());
|
||||||
|
m.insert(
|
||||||
|
"next_cursor".into(),
|
||||||
|
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||||
|
);
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the `{ id, data, created_at, updated_at }` envelope per
|
/// Build the `{ id, data, created_at, updated_at }` envelope per
|
||||||
/// Decision D. Scripts read user fields via `doc.data.<field>`; `id`
|
/// Decision D. Scripts read user fields via `doc.data.<field>`; `id`
|
||||||
/// and timestamps are direct children of the envelope.
|
/// and timestamps are direct children of the envelope.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use super::bridge::block_on;
|
use super::bridge::{block_on, runtime_err};
|
||||||
use picloud_shared::{OutboundEmail, SdkCallCx, Services};
|
use picloud_shared::{OutboundEmail, SdkCallCx, Services};
|
||||||
use rhai::{Array, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
use rhai::{Array, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
|
|
||||||
@@ -124,8 +124,3 @@ fn addresses(opts: &Map, key: &str) -> Result<Vec<String>, Box<EvalAltResult>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::unnecessary_box_returns)]
|
|
||||||
fn runtime_err(msg: &str) -> Box<EvalAltResult> {
|
|
||||||
EvalAltResult::ErrorRuntime(msg.into(), rhai::Position::NONE).into()
|
|
||||||
}
|
|
||||||
|
|||||||
124
crates/executor-core/src/sdk/emit_budget.rs
Normal file
124
crates/executor-core/src/sdk/emit_budget.rs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
//! Per-execution fan-out ceiling (audit fix #2).
|
||||||
|
//!
|
||||||
|
//! `trigger_depth` bounds how DEEP a trigger/invoke chain can go, but nothing
|
||||||
|
//! bounded how WIDE a single execution could fan out: one anonymous request
|
||||||
|
//! running `for i in 0..1_000_000 { invoke_async("w", #{}) }` costs a few Rhai
|
||||||
|
//! ops per iteration plus one cheap outbox INSERT, so within the op / wall-clock
|
||||||
|
//! budget it could write ~10^5 durable rows — each dispatched as its own
|
||||||
|
//! execution — flooding the outbox/queue (a durable amplification DoS).
|
||||||
|
//!
|
||||||
|
//! This caps the number of DURABLE emissions (`invoke_async`, `publish_durable`,
|
||||||
|
//! `enqueue`, and their group-shared variants) a single execution may make.
|
||||||
|
//! The counter is a thread-local. [`EmissionBudgetScope`] is created around
|
||||||
|
//! EVERY `execute_ast` and is **re-entrancy aware**: only the OUTERMOST scope
|
||||||
|
//! on a thread resets the counter. A dispatched handler (queue/trigger/
|
||||||
|
//! `invoke_async`/cron/workflow) is a fresh task on a pooled thread, so its
|
||||||
|
//! scope is outermost → it resets and gets a full budget. A synchronous
|
||||||
|
//! `invoke()` / interceptor re-entry runs a nested `execute_ast` in the SAME
|
||||||
|
//! call stack, so its scope is inner → it does NOT reset, and the whole
|
||||||
|
//! synchronous chain shares one budget (fan-out counted across it). The
|
||||||
|
//! outermost Drop re-zeroes the counter so a pooled thread never leaks a count
|
||||||
|
//! into the next task.
|
||||||
|
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use rhai::EvalAltResult;
|
||||||
|
|
||||||
|
use crate::sdk::bridge::runtime_err;
|
||||||
|
|
||||||
|
/// Default max durable emissions per execution. Generous for a legit batch
|
||||||
|
/// producer, far below the ~10^5 a runaway loop can reach within budget.
|
||||||
|
const DEFAULT_MAX_EMISSIONS: u32 = 1000;
|
||||||
|
|
||||||
|
static MAX_EMISSIONS: LazyLock<u32> = LazyLock::new(|| {
|
||||||
|
std::env::var("PICLOUD_MAX_EMISSIONS_PER_EXECUTION")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse::<u32>().ok())
|
||||||
|
.filter(|&n| n > 0)
|
||||||
|
.unwrap_or(DEFAULT_MAX_EMISSIONS)
|
||||||
|
});
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static EMISSIONS: Cell<u32> = const { Cell::new(0) };
|
||||||
|
/// Scope nesting depth on this thread. `0` when no execution is active.
|
||||||
|
static ACTIVE: Cell<u32> = const { Cell::new(0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge one durable emission against the current execution's budget. Returns
|
||||||
|
/// an `Err` (aborting the emit) once the ceiling is exceeded.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns a Rhai runtime error when the per-execution emission ceiling is hit.
|
||||||
|
pub(super) fn charge_emission(service: &str) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
EMISSIONS.with(|c| {
|
||||||
|
let next = c.get().saturating_add(1);
|
||||||
|
if next > *MAX_EMISSIONS {
|
||||||
|
return Err(runtime_err(&format!(
|
||||||
|
"{service}: per-execution emission limit exceeded (max {} durable \
|
||||||
|
invoke_async/publish/enqueue calls per execution)",
|
||||||
|
*MAX_EMISSIONS
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
c.set(next);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RAII scope wrapping one `execute_ast`. Re-entrancy aware: the OUTERMOST scope
|
||||||
|
/// on a thread zeroes the counter on entry and on final exit; nested scopes (a
|
||||||
|
/// synchronous re-entry in the same call stack) leave it alone, so the chain
|
||||||
|
/// shares one budget.
|
||||||
|
pub(crate) struct EmissionBudgetScope;
|
||||||
|
|
||||||
|
impl EmissionBudgetScope {
|
||||||
|
pub(crate) fn enter() -> Self {
|
||||||
|
ACTIVE.with(|a| {
|
||||||
|
if a.get() == 0 {
|
||||||
|
EMISSIONS.with(|c| c.set(0));
|
||||||
|
}
|
||||||
|
a.set(a.get() + 1);
|
||||||
|
});
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for EmissionBudgetScope {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
ACTIVE.with(|a| {
|
||||||
|
let n = a.get().saturating_sub(1);
|
||||||
|
a.set(n);
|
||||||
|
if n == 0 {
|
||||||
|
EMISSIONS.with(|c| c.set(0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outermost_scope_resets_but_nested_shares_budget() {
|
||||||
|
let outer = EmissionBudgetScope::enter();
|
||||||
|
for _ in 0..DEFAULT_MAX_EMISSIONS {
|
||||||
|
charge_emission("test").expect("under budget");
|
||||||
|
}
|
||||||
|
// A NESTED scope (synchronous re-entry) must NOT reset — the budget is
|
||||||
|
// already exhausted and stays that way.
|
||||||
|
{
|
||||||
|
let _nested = EmissionBudgetScope::enter();
|
||||||
|
assert!(
|
||||||
|
charge_emission("test").is_err(),
|
||||||
|
"a nested re-entry shares the exhausted budget"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Still exhausted after the nested scope drops (outer still active).
|
||||||
|
assert!(charge_emission("test").is_err());
|
||||||
|
drop(outer);
|
||||||
|
// A fresh OUTERMOST scope resets.
|
||||||
|
let _fresh = EmissionBudgetScope::enter();
|
||||||
|
charge_emission("test").expect("reset by a fresh outermost scope");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,10 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use super::bridge::block_on;
|
use super::bridge::block_on;
|
||||||
use picloud_shared::{FileMeta, FileUpdate, FilesService, NewFile, SdkCallCx, Services};
|
use super::interceptor::InterceptorCtx;
|
||||||
|
use picloud_shared::{
|
||||||
|
FileMeta, FileUpdate, FilesService, GroupFilesService, NewFile, SdkCallCx, Services,
|
||||||
|
};
|
||||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
|
|
||||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||||
@@ -34,15 +37,39 @@ pub struct FilesHandle {
|
|||||||
collection: String,
|
collection: String,
|
||||||
service: Arc<dyn FilesService>,
|
service: Arc<dyn FilesService>,
|
||||||
cx: Arc<SdkCallCx>,
|
cx: Arc<SdkCallCx>,
|
||||||
|
// §9.4: carried so `create`/`update`/`delete` run the before/after hook (M9).
|
||||||
|
// Files carry bytes, so the hook sees service/op/collection/key only — the
|
||||||
|
// blob is never materialized into a `value`, and there is no data transform.
|
||||||
|
ictx: InterceptorCtx,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
/// §11.6 shared-collection handle, returned by `files::shared_collection(name)`.
|
||||||
|
/// Same method surface as `FilesHandle`, but routes through the
|
||||||
|
/// `GroupFilesService` — the owning group is resolved from `cx.app_id`'s
|
||||||
|
/// ancestor chain inside the service (the isolation boundary).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GroupFilesHandle {
|
||||||
|
collection: String,
|
||||||
|
service: Arc<dyn GroupFilesService>,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
// §9.4: carried so shared `create`/`update`/`delete` run the hook too (M9).
|
||||||
|
ictx: InterceptorCtx,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn register(
|
||||||
|
engine: &mut RhaiEngine,
|
||||||
|
services: &Services,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
ictx: InterceptorCtx,
|
||||||
|
) {
|
||||||
let files_service = services.files.clone();
|
let files_service = services.files.clone();
|
||||||
|
let group_files_service = services.group_files.clone();
|
||||||
|
|
||||||
let mut module = Module::new();
|
let mut module = Module::new();
|
||||||
{
|
{
|
||||||
let files_service = files_service.clone();
|
let files_service = files_service.clone();
|
||||||
let cx = cx.clone();
|
let cx = cx.clone();
|
||||||
|
let ictx = ictx.clone();
|
||||||
module.set_native_fn(
|
module.set_native_fn(
|
||||||
"collection",
|
"collection",
|
||||||
move |name: &str| -> Result<FilesHandle, Box<EvalAltResult>> {
|
move |name: &str| -> Result<FilesHandle, Box<EvalAltResult>> {
|
||||||
@@ -53,6 +80,26 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
collection: name.to_string(),
|
collection: name.to_string(),
|
||||||
service: files_service.clone(),
|
service: files_service.clone(),
|
||||||
cx: cx.clone(),
|
cx: cx.clone(),
|
||||||
|
ictx: ictx.clone(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let group_files_service = group_files_service.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let ictx = ictx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"shared_collection",
|
||||||
|
move |name: &str| -> Result<GroupFilesHandle, Box<EvalAltResult>> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("files::shared_collection name must not be empty".into());
|
||||||
|
}
|
||||||
|
Ok(GroupFilesHandle {
|
||||||
|
collection: name.to_string(),
|
||||||
|
service: group_files_service.clone(),
|
||||||
|
cx: cx.clone(),
|
||||||
|
ictx: ictx.clone(),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -67,6 +114,15 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
register_update(engine);
|
register_update(engine);
|
||||||
register_delete(engine);
|
register_delete(engine);
|
||||||
register_list(engine);
|
register_list(engine);
|
||||||
|
|
||||||
|
// Same method names on GroupFilesHandle — Rhai dispatches by receiver type.
|
||||||
|
engine.register_type_with_name::<GroupFilesHandle>("GroupFilesHandle");
|
||||||
|
register_group_create(engine);
|
||||||
|
register_group_head(engine);
|
||||||
|
register_group_get(engine);
|
||||||
|
register_group_update(engine);
|
||||||
|
register_group_delete(engine);
|
||||||
|
register_group_list(engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register_create(engine: &mut RhaiEngine) {
|
fn register_create(engine: &mut RhaiEngine) {
|
||||||
@@ -76,6 +132,9 @@ fn register_create(engine: &mut RhaiEngine) {
|
|||||||
let name = require_string(&meta, "name")?;
|
let name = require_string(&meta, "name")?;
|
||||||
let content_type = require_string(&meta, "content_type")?;
|
let content_type = require_string(&meta, "content_type")?;
|
||||||
let data = require_blob(&meta, "data")?;
|
let data = require_blob(&meta, "data")?;
|
||||||
|
// §9.4 before-op interceptor (allow/deny only — no transform for
|
||||||
|
// files, the blob is not surfaced to the hook).
|
||||||
|
run_before(handle, "create", "")?;
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let new = NewFile {
|
let new = NewFile {
|
||||||
name,
|
name,
|
||||||
@@ -85,7 +144,14 @@ fn register_create(engine: &mut RhaiEngine) {
|
|||||||
let id = block_on("files", async move {
|
let id = block_on("files", async move {
|
||||||
h.service.create(&h.cx, &h.collection, new).await
|
h.service.create(&h.cx, &h.collection, new).await
|
||||||
})?;
|
})?;
|
||||||
Ok(id.to_string())
|
let id_str = id.to_string();
|
||||||
|
run_after(
|
||||||
|
handle,
|
||||||
|
"create",
|
||||||
|
"",
|
||||||
|
serde_json::Value::String(id_str.clone()),
|
||||||
|
)?;
|
||||||
|
Ok(id_str)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -125,16 +191,18 @@ fn register_update(engine: &mut RhaiEngine) {
|
|||||||
let data = require_blob(&meta, "data")?;
|
let data = require_blob(&meta, "data")?;
|
||||||
let name = optional_string(&meta, "name")?;
|
let name = optional_string(&meta, "name")?;
|
||||||
let content_type = optional_string(&meta, "content_type")?;
|
let content_type = optional_string(&meta, "content_type")?;
|
||||||
|
run_before(handle, "update", id)?;
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let id = id.to_string();
|
let id_owned = id.to_string();
|
||||||
let upd = FileUpdate {
|
let upd = FileUpdate {
|
||||||
data,
|
data,
|
||||||
name,
|
name,
|
||||||
content_type,
|
content_type,
|
||||||
};
|
};
|
||||||
block_on("files", async move {
|
block_on("files", async move {
|
||||||
h.service.update(&h.cx, &h.collection, &id, upd).await
|
h.service.update(&h.cx, &h.collection, &id_owned, upd).await
|
||||||
})
|
})?;
|
||||||
|
run_after(handle, "update", id, serde_json::Value::Null)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -143,15 +211,52 @@ fn register_delete(engine: &mut RhaiEngine) {
|
|||||||
engine.register_fn(
|
engine.register_fn(
|
||||||
"delete",
|
"delete",
|
||||||
|handle: &mut FilesHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
|handle: &mut FilesHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
run_before(handle, "delete", id)?;
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let id = id.to_string();
|
let id_owned = id.to_string();
|
||||||
block_on("files", async move {
|
let was_present = block_on("files", async move {
|
||||||
h.service.delete(&h.cx, &h.collection, &id).await
|
h.service.delete(&h.cx, &h.collection, &id_owned).await
|
||||||
})
|
})?;
|
||||||
|
run_after(handle, "delete", id, serde_json::Value::Bool(was_present))?;
|
||||||
|
Ok(was_present)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §9.4: before/after hooks for a per-app files write. Files pass `value = None`
|
||||||
|
/// (the blob is never surfaced to the hook), so the before-hook only allows or
|
||||||
|
/// denies — it never transforms.
|
||||||
|
fn run_before(handle: &FilesHandle, op: &'static str, key: &str) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
super::interceptor::run_before(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"files",
|
||||||
|
op,
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_after(
|
||||||
|
handle: &FilesHandle,
|
||||||
|
op: &'static str,
|
||||||
|
key: &str,
|
||||||
|
result: serde_json::Value,
|
||||||
|
) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
super::interceptor::run_after(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"files",
|
||||||
|
op,
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
None,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn register_list(engine: &mut RhaiEngine) {
|
fn register_list(engine: &mut RhaiEngine) {
|
||||||
engine.register_fn(
|
engine.register_fn(
|
||||||
"list",
|
"list",
|
||||||
@@ -220,6 +325,213 @@ fn list_call(
|
|||||||
Ok(m)
|
Ok(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- GroupFilesHandle methods (§11.6 shared files collections) -------------
|
||||||
|
// Bodies mirror the app handle's; only the receiver type and service differ
|
||||||
|
// (Rhai dispatches `create`/`head`/... by the handle's concrete type).
|
||||||
|
|
||||||
|
fn register_group_create(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"create",
|
||||||
|
|handle: &mut GroupFilesHandle, meta: Map| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
let name = require_string(&meta, "name")?;
|
||||||
|
let content_type = require_string(&meta, "content_type")?;
|
||||||
|
let data = require_blob(&meta, "data")?;
|
||||||
|
group_run_before(handle, "create", "")?;
|
||||||
|
let h = handle.clone();
|
||||||
|
let new = NewFile {
|
||||||
|
name,
|
||||||
|
content_type,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
let id = block_on("files", async move {
|
||||||
|
h.service.create(&h.cx, &h.collection, new).await
|
||||||
|
})?;
|
||||||
|
let id_str = id.to_string();
|
||||||
|
group_run_after(
|
||||||
|
handle,
|
||||||
|
"create",
|
||||||
|
"",
|
||||||
|
serde_json::Value::String(id_str.clone()),
|
||||||
|
)?;
|
||||||
|
Ok(id_str)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_head(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"head",
|
||||||
|
|handle: &mut GroupFilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let id = id.to_string();
|
||||||
|
let meta = block_on("files", async move {
|
||||||
|
h.service.head(&h.cx, &h.collection, &id).await
|
||||||
|
})?;
|
||||||
|
Ok(meta.map_or(Dynamic::UNIT, |m| file_meta_to_map(&m).into()))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_get(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"get",
|
||||||
|
|handle: &mut GroupFilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let id = id.to_string();
|
||||||
|
let bytes = block_on("files", async move {
|
||||||
|
h.service.get(&h.cx, &h.collection, &id).await
|
||||||
|
})?;
|
||||||
|
Ok(bytes.map_or(Dynamic::UNIT, Dynamic::from_blob))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_update(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"update",
|
||||||
|
|handle: &mut GroupFilesHandle, id: &str, meta: Map| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let data = require_blob(&meta, "data")?;
|
||||||
|
let name = optional_string(&meta, "name")?;
|
||||||
|
let content_type = optional_string(&meta, "content_type")?;
|
||||||
|
group_run_before(handle, "update", id)?;
|
||||||
|
let h = handle.clone();
|
||||||
|
let id_owned = id.to_string();
|
||||||
|
let upd = FileUpdate {
|
||||||
|
data,
|
||||||
|
name,
|
||||||
|
content_type,
|
||||||
|
};
|
||||||
|
block_on("files", async move {
|
||||||
|
h.service.update(&h.cx, &h.collection, &id_owned, upd).await
|
||||||
|
})?;
|
||||||
|
group_run_after(handle, "update", id, serde_json::Value::Null)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_delete(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"delete",
|
||||||
|
|handle: &mut GroupFilesHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
group_run_before(handle, "delete", id)?;
|
||||||
|
let h = handle.clone();
|
||||||
|
let id_owned = id.to_string();
|
||||||
|
let was_present = block_on("files", async move {
|
||||||
|
h.service.delete(&h.cx, &h.collection, &id_owned).await
|
||||||
|
})?;
|
||||||
|
group_run_after(handle, "delete", id, serde_json::Value::Bool(was_present))?;
|
||||||
|
Ok(was_present)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §9.4: before/after hooks for a shared (group) files write — mirror the
|
||||||
|
/// per-app helpers but over `GroupFilesHandle`.
|
||||||
|
fn group_run_before(
|
||||||
|
handle: &GroupFilesHandle,
|
||||||
|
op: &'static str,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
super::interceptor::run_before(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"files",
|
||||||
|
op,
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_run_after(
|
||||||
|
handle: &GroupFilesHandle,
|
||||||
|
op: &'static str,
|
||||||
|
key: &str,
|
||||||
|
result: serde_json::Value,
|
||||||
|
) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
super::interceptor::run_after(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"files",
|
||||||
|
op,
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
None,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_list(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupFilesHandle| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
group_list_call(handle, None, 0)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupFilesHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
group_list_call(handle, Some(cursor.to_string()), 0)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupFilesHandle,
|
||||||
|
cursor: &str,
|
||||||
|
limit: i64|
|
||||||
|
-> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let limit = u32::try_from(limit.max(0)).unwrap_or(0);
|
||||||
|
group_list_call(handle, Some(cursor.to_string()), limit)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupFilesHandle, opts: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let cursor = match opts.get("cursor") {
|
||||||
|
Some(v) if !v.is_unit() => {
|
||||||
|
Some(v.clone().into_string().map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
"files: list cursor must be a string".into()
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let limit = match opts.get("limit") {
|
||||||
|
Some(v) if !v.is_unit() => {
|
||||||
|
u32::try_from(v.as_int().unwrap_or(0).max(0)).unwrap_or(0)
|
||||||
|
}
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
group_list_call(handle, cursor, limit)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_list_call(
|
||||||
|
handle: &GroupFilesHandle,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let page = block_on("files", async move {
|
||||||
|
h.service
|
||||||
|
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||||
|
.await
|
||||||
|
})?;
|
||||||
|
let mut m = Map::new();
|
||||||
|
let files: Array = page
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.map(|meta| Dynamic::from(file_meta_to_map(meta)))
|
||||||
|
.collect();
|
||||||
|
m.insert("files".into(), files.into());
|
||||||
|
m.insert(
|
||||||
|
"next_cursor".into(),
|
||||||
|
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||||
|
);
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
/// Render a `FileMeta` into the Rhai map shape scripts see from
|
/// Render a `FileMeta` into the Rhai map shape scripts see from
|
||||||
/// `head` / `list`.
|
/// `head` / `list`.
|
||||||
fn file_meta_to_map(meta: &FileMeta) -> Map {
|
fn file_meta_to_map(meta: &FileMeta) -> Map {
|
||||||
|
|||||||
@@ -32,11 +32,13 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use picloud_shared::{HttpError, HttpRequest, HttpResponse, HttpService, SdkCallCx, Services};
|
use picloud_shared::{HttpRequest, HttpResponse, HttpService, SdkCallCx, Services};
|
||||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
use tokio::runtime::Handle as TokioHandle;
|
|
||||||
|
|
||||||
use super::bridge::{dynamic_to_json, json_to_dynamic};
|
use super::bridge::{
|
||||||
|
block_on, dynamic_to_json_capped, json_to_dynamic, runtime_err, MAX_JSON_MATERIALIZE_BYTES,
|
||||||
|
};
|
||||||
|
use super::interceptor::InterceptorCtx;
|
||||||
|
|
||||||
/// Bridge-side defaults (the service clamps server-side too). The
|
/// Bridge-side defaults (the service clamps server-side too). The
|
||||||
/// `MAX_*` ceilings stay `i64` because they're compared against the
|
/// `MAX_*` ceilings stay `i64` because they're compared against the
|
||||||
@@ -49,20 +51,31 @@ const MAX_REDIRECTS: i64 = 10;
|
|||||||
|
|
||||||
const ALLOWED_OPT_KEYS: [&str; 4] = ["headers", "timeout_ms", "follow_redirects", "max_redirects"];
|
const ALLOWED_OPT_KEYS: [&str; 4] = ["headers", "timeout_ms", "follow_redirects", "max_redirects"];
|
||||||
|
|
||||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
// `http` registers its verbs as module native fns (no handle struct), so the
|
||||||
|
// interceptor ctx is captured into each request closure directly. Every verb
|
||||||
|
// funnels through `invoke` / `invoke_form`, which run the §9.4 before/after hook
|
||||||
|
// around `svc.request` (M11). The hook sees `service = "http"`, `op =
|
||||||
|
// "request"`, `collection = <METHOD>`, `key = <url>`; the body is never
|
||||||
|
// surfaced, so there is no data transform.
|
||||||
|
pub(super) fn register(
|
||||||
|
engine: &mut RhaiEngine,
|
||||||
|
services: &Services,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
ictx: InterceptorCtx,
|
||||||
|
) {
|
||||||
let svc = services.http.clone();
|
let svc = services.http.clone();
|
||||||
let mut module = Module::new();
|
let mut module = Module::new();
|
||||||
|
|
||||||
// Bodyless verbs: (url) / (url, opts).
|
// Bodyless verbs: (url) / (url, opts).
|
||||||
for verb in ["get", "head"] {
|
for verb in ["get", "head"] {
|
||||||
register_bodyless(&mut module, verb, &svc, &cx);
|
register_bodyless(&mut module, verb, &svc, &cx, &ictx);
|
||||||
}
|
}
|
||||||
// Body verbs: (url) / (url, body) / (url, body, opts).
|
// Body verbs: (url) / (url, body) / (url, body, opts).
|
||||||
for verb in ["post", "put", "patch", "delete"] {
|
for verb in ["post", "put", "patch", "delete"] {
|
||||||
register_body(&mut module, verb, &svc, &cx);
|
register_body(&mut module, verb, &svc, &cx, &ictx);
|
||||||
}
|
}
|
||||||
register_post_form(&mut module, &svc, &cx);
|
register_post_form(&mut module, &svc, &cx, &ictx);
|
||||||
register_request(&mut module, &svc, &cx);
|
register_request(&mut module, &svc, &cx, &ictx);
|
||||||
|
|
||||||
engine.register_static_module("http", module.into());
|
engine.register_static_module("http", module.into());
|
||||||
}
|
}
|
||||||
@@ -72,17 +85,18 @@ fn register_bodyless(
|
|||||||
verb: &'static str,
|
verb: &'static str,
|
||||||
svc: &Arc<dyn HttpService>,
|
svc: &Arc<dyn HttpService>,
|
||||||
cx: &Arc<SdkCallCx>,
|
cx: &Arc<SdkCallCx>,
|
||||||
|
ictx: &InterceptorCtx,
|
||||||
) {
|
) {
|
||||||
{
|
{
|
||||||
let (svc, cx) = (svc.clone(), cx.clone());
|
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
|
||||||
module.set_native_fn(verb, move |url: &str| {
|
module.set_native_fn(verb, move |url: &str| {
|
||||||
invoke(&svc, &cx, verb, url, None, None)
|
invoke(&ictx, &svc, &cx, verb, url, None, None)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
let (svc, cx) = (svc.clone(), cx.clone());
|
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
|
||||||
module.set_native_fn(verb, move |url: &str, opts: Map| {
|
module.set_native_fn(verb, move |url: &str, opts: Map| {
|
||||||
invoke(&svc, &cx, verb, url, None, Some(&opts))
|
invoke(&ictx, &svc, &cx, verb, url, None, Some(&opts))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,61 +106,72 @@ fn register_body(
|
|||||||
verb: &'static str,
|
verb: &'static str,
|
||||||
svc: &Arc<dyn HttpService>,
|
svc: &Arc<dyn HttpService>,
|
||||||
cx: &Arc<SdkCallCx>,
|
cx: &Arc<SdkCallCx>,
|
||||||
|
ictx: &InterceptorCtx,
|
||||||
) {
|
) {
|
||||||
{
|
{
|
||||||
let (svc, cx) = (svc.clone(), cx.clone());
|
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
|
||||||
module.set_native_fn(verb, move |url: &str| {
|
module.set_native_fn(verb, move |url: &str| {
|
||||||
invoke(&svc, &cx, verb, url, None, None)
|
invoke(&ictx, &svc, &cx, verb, url, None, None)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
let (svc, cx) = (svc.clone(), cx.clone());
|
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
|
||||||
module.set_native_fn(verb, move |url: &str, body: Dynamic| {
|
module.set_native_fn(verb, move |url: &str, body: Dynamic| {
|
||||||
invoke(&svc, &cx, verb, url, Some(body), None)
|
invoke(&ictx, &svc, &cx, verb, url, Some(body), None)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
let (svc, cx) = (svc.clone(), cx.clone());
|
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
|
||||||
module.set_native_fn(verb, move |url: &str, body: Dynamic, opts: Map| {
|
module.set_native_fn(verb, move |url: &str, body: Dynamic, opts: Map| {
|
||||||
invoke(&svc, &cx, verb, url, Some(body), Some(&opts))
|
invoke(&ictx, &svc, &cx, verb, url, Some(body), Some(&opts))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register_post_form(module: &mut Module, svc: &Arc<dyn HttpService>, cx: &Arc<SdkCallCx>) {
|
fn register_post_form(
|
||||||
|
module: &mut Module,
|
||||||
|
svc: &Arc<dyn HttpService>,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
ictx: &InterceptorCtx,
|
||||||
|
) {
|
||||||
{
|
{
|
||||||
let (svc, cx) = (svc.clone(), cx.clone());
|
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
|
||||||
module.set_native_fn("post_form", move |url: &str, form: Map| {
|
module.set_native_fn("post_form", move |url: &str, form: Map| {
|
||||||
invoke_form(&svc, &cx, url, &form, None)
|
invoke_form(&ictx, &svc, &cx, url, &form, None)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
let (svc, cx) = (svc.clone(), cx.clone());
|
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
|
||||||
module.set_native_fn("post_form", move |url: &str, form: Map, opts: Map| {
|
module.set_native_fn("post_form", move |url: &str, form: Map, opts: Map| {
|
||||||
invoke_form(&svc, &cx, url, &form, Some(&opts))
|
invoke_form(&ictx, &svc, &cx, url, &form, Some(&opts))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register_request(module: &mut Module, svc: &Arc<dyn HttpService>, cx: &Arc<SdkCallCx>) {
|
fn register_request(
|
||||||
|
module: &mut Module,
|
||||||
|
svc: &Arc<dyn HttpService>,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
ictx: &InterceptorCtx,
|
||||||
|
) {
|
||||||
{
|
{
|
||||||
let (svc, cx) = (svc.clone(), cx.clone());
|
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
|
||||||
module.set_native_fn("request", move |method: &str, url: &str| {
|
module.set_native_fn("request", move |method: &str, url: &str| {
|
||||||
invoke(&svc, &cx, method, url, None, None)
|
invoke(&ictx, &svc, &cx, method, url, None, None)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
let (svc, cx) = (svc.clone(), cx.clone());
|
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
|
||||||
module.set_native_fn("request", move |method: &str, url: &str, body: Dynamic| {
|
module.set_native_fn("request", move |method: &str, url: &str, body: Dynamic| {
|
||||||
invoke(&svc, &cx, method, url, Some(body), None)
|
invoke(&ictx, &svc, &cx, method, url, Some(body), None)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
let (svc, cx) = (svc.clone(), cx.clone());
|
let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone());
|
||||||
module.set_native_fn(
|
module.set_native_fn(
|
||||||
"request",
|
"request",
|
||||||
move |method: &str, url: &str, body: Dynamic, opts: Map| {
|
move |method: &str, url: &str, body: Dynamic, opts: Map| {
|
||||||
invoke(&svc, &cx, method, url, Some(body), Some(&opts))
|
invoke(&ictx, &svc, &cx, method, url, Some(body), Some(&opts))
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -237,20 +262,21 @@ fn dispatch_body(body: Dynamic) -> Result<EncodedBody, Box<EvalAltResult>> {
|
|||||||
return Ok((Some(s.into_bytes()), Some("text/plain".to_string())));
|
return Ok((Some(s.into_bytes()), Some("text/plain".to_string())));
|
||||||
}
|
}
|
||||||
if body.is_map() || body.is_array() {
|
if body.is_map() || body.is_array() {
|
||||||
let json = dynamic_to_json(&body);
|
let json = dynamic_to_json_capped(&body, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
let bytes = serde_json::to_vec(&json)
|
let bytes = serde_json::to_vec(&json)
|
||||||
.map_err(|e| err(format!("could not encode JSON body: {e}")))?;
|
.map_err(|e| err(format!("could not encode JSON body: {e}")))?;
|
||||||
return Ok((Some(bytes), Some("application/json".to_string())));
|
return Ok((Some(bytes), Some("application/json".to_string())));
|
||||||
}
|
}
|
||||||
// Scalars (int/float/bool) → JSON-encode for consistency.
|
// Scalars (int/float/bool) → JSON-encode for consistency.
|
||||||
let json = dynamic_to_json(&body);
|
let json = dynamic_to_json_capped(&body, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
let bytes =
|
let bytes =
|
||||||
serde_json::to_vec(&json).map_err(|e| err(format!("could not encode body: {e}")))?;
|
serde_json::to_vec(&json).map_err(|e| err(format!("could not encode body: {e}")))?;
|
||||||
Ok((Some(bytes), Some("application/json".to_string())))
|
Ok((Some(bytes), Some("application/json".to_string())))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)]
|
||||||
fn invoke(
|
fn invoke(
|
||||||
|
ictx: &InterceptorCtx,
|
||||||
svc: &Arc<dyn HttpService>,
|
svc: &Arc<dyn HttpService>,
|
||||||
cx: &Arc<SdkCallCx>,
|
cx: &Arc<SdkCallCx>,
|
||||||
method: &str,
|
method: &str,
|
||||||
@@ -260,6 +286,9 @@ fn invoke(
|
|||||||
) -> Result<Dynamic, Box<EvalAltResult>> {
|
) -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
let opts = parse_opts(opts)?;
|
let opts = parse_opts(opts)?;
|
||||||
let method_uc = method.to_ascii_uppercase();
|
let method_uc = method.to_ascii_uppercase();
|
||||||
|
// §9.4 before-op interceptor (allow/deny only; the body is not surfaced to
|
||||||
|
// the hook). `collection` = the HTTP method, `key` = the URL.
|
||||||
|
super::interceptor::run_before(ictx, cx, "http", "request", &method_uc, url, None)?;
|
||||||
let bodyless = matches!(method_uc.as_str(), "GET" | "HEAD");
|
let bodyless = matches!(method_uc.as_str(), "GET" | "HEAD");
|
||||||
let (encoded, content_type) = if bodyless {
|
let (encoded, content_type) = if bodyless {
|
||||||
(None, None)
|
(None, None)
|
||||||
@@ -270,7 +299,7 @@ fn invoke(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let req = HttpRequest {
|
let req = HttpRequest {
|
||||||
method: method_uc,
|
method: method_uc.clone(),
|
||||||
url: url.to_string(),
|
url: url.to_string(),
|
||||||
headers: opts.headers,
|
headers: opts.headers,
|
||||||
body: encoded,
|
body: encoded,
|
||||||
@@ -280,12 +309,27 @@ fn invoke(
|
|||||||
max_redirects: opts.max_redirects,
|
max_redirects: opts.max_redirects,
|
||||||
script_id: Some(cx.script_id.to_string()),
|
script_id: Some(cx.script_id.to_string()),
|
||||||
};
|
};
|
||||||
let resp = block_on(svc, cx, req)?;
|
let resp = {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
block_on("http", async move { svc.request(&cx, req).await })?
|
||||||
|
};
|
||||||
|
super::interceptor::run_after(
|
||||||
|
ictx,
|
||||||
|
cx,
|
||||||
|
"http",
|
||||||
|
"request",
|
||||||
|
&method_uc,
|
||||||
|
url,
|
||||||
|
None,
|
||||||
|
serde_json::Value::Null,
|
||||||
|
)?;
|
||||||
Ok(response_to_dynamic(&resp))
|
Ok(response_to_dynamic(&resp))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn invoke_form(
|
fn invoke_form(
|
||||||
|
ictx: &InterceptorCtx,
|
||||||
svc: &Arc<dyn HttpService>,
|
svc: &Arc<dyn HttpService>,
|
||||||
cx: &Arc<SdkCallCx>,
|
cx: &Arc<SdkCallCx>,
|
||||||
url: &str,
|
url: &str,
|
||||||
@@ -293,6 +337,8 @@ fn invoke_form(
|
|||||||
opts: Option<&Map>,
|
opts: Option<&Map>,
|
||||||
) -> Result<Dynamic, Box<EvalAltResult>> {
|
) -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
let opts = parse_opts(opts)?;
|
let opts = parse_opts(opts)?;
|
||||||
|
// §9.4 before-op interceptor. `post_form` is always a POST.
|
||||||
|
super::interceptor::run_before(ictx, cx, "http", "request", "POST", url, None)?;
|
||||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||||
for (k, v) in form {
|
for (k, v) in form {
|
||||||
serializer.append_pair(k.as_str(), &dyn_to_string(v));
|
serializer.append_pair(k.as_str(), &dyn_to_string(v));
|
||||||
@@ -310,7 +356,21 @@ fn invoke_form(
|
|||||||
max_redirects: opts.max_redirects,
|
max_redirects: opts.max_redirects,
|
||||||
script_id: Some(cx.script_id.to_string()),
|
script_id: Some(cx.script_id.to_string()),
|
||||||
};
|
};
|
||||||
let resp = block_on(svc, cx, req)?;
|
let resp = {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
block_on("http", async move { svc.request(&cx, req).await })?
|
||||||
|
};
|
||||||
|
super::interceptor::run_after(
|
||||||
|
ictx,
|
||||||
|
cx,
|
||||||
|
"http",
|
||||||
|
"request",
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
None,
|
||||||
|
serde_json::Value::Null,
|
||||||
|
)?;
|
||||||
Ok(response_to_dynamic(&resp))
|
Ok(response_to_dynamic(&resp))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,36 +416,10 @@ fn dyn_to_string(v: &Dynamic) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rhai's native-fn error channel is `Box<EvalAltResult>`, so these
|
// A validation error, prefixed like the service's runtime errors (the shared
|
||||||
// helpers return the boxed form the call sites need.
|
// `bridge::block_on("http", …)` prefixes the same way, so messages are uniform).
|
||||||
|
// Delegates to `bridge::runtime_err`; the boxed return is Rhai's error channel.
|
||||||
#[allow(clippy::unnecessary_box_returns)]
|
#[allow(clippy::unnecessary_box_returns)]
|
||||||
fn err(msg: String) -> Box<EvalAltResult> {
|
fn err(msg: String) -> Box<EvalAltResult> {
|
||||||
EvalAltResult::ErrorRuntime(format!("http: {msg}").into(), rhai::Position::NONE).into()
|
runtime_err(&format!("http: {msg}"))
|
||||||
}
|
|
||||||
|
|
||||||
/// Run the async service call from the synchronous Rhai context. Same
|
|
||||||
/// pattern as `kv`/`docs`: the script runs under `spawn_blocking`, so a
|
|
||||||
/// runtime handle is reachable and blocking on it is correct.
|
|
||||||
fn block_on(
|
|
||||||
svc: &Arc<dyn HttpService>,
|
|
||||||
cx: &Arc<SdkCallCx>,
|
|
||||||
req: HttpRequest,
|
|
||||||
) -> Result<HttpResponse, Box<EvalAltResult>> {
|
|
||||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
|
||||||
EvalAltResult::ErrorRuntime(
|
|
||||||
format!("http: no tokio runtime available: {e}").into(),
|
|
||||||
rhai::Position::NONE,
|
|
||||||
)
|
|
||||||
.into()
|
|
||||||
})?;
|
|
||||||
let svc = svc.clone();
|
|
||||||
let cx = cx.clone();
|
|
||||||
handle
|
|
||||||
.block_on(async move { svc.request(&cx, req).await })
|
|
||||||
.map_err(map_http_err)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::unnecessary_box_returns)]
|
|
||||||
fn map_http_err(e: HttpError) -> Box<EvalAltResult> {
|
|
||||||
EvalAltResult::ErrorRuntime(format!("http: {e}").into(), rhai::Position::NONE).into()
|
|
||||||
}
|
}
|
||||||
|
|||||||
417
crates/executor-core/src/sdk/interceptor.rs
Normal file
417
crates/executor-core/src/sdk/interceptor.rs
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
//! §9.4 Service Interceptors — the executor-side before-op hook.
|
||||||
|
//!
|
||||||
|
//! MVP: `kv::set` / `kv::delete` (private AND group-shared collections) run an
|
||||||
|
//! allow/deny interceptor first. The hook resolves the nearest interceptor for
|
||||||
|
//! `(service, op)` on the calling app's chain via the injected
|
||||||
|
//! `InterceptorService` (a cheap indexed query; `None` = un-hooked → allow, no
|
||||||
|
//! further work). The resolver returns a script already **sealed to the owner
|
||||||
|
//! that declared the marker** and fully materialized, so we run it straight
|
||||||
|
//! through the SAME `invoke()` re-entry core (`run_resolved_blocking`) — no
|
||||||
|
//! second dispatch mechanism, no re-resolution by name (which would let a
|
||||||
|
//! descendant app shadow a group's guard).
|
||||||
|
//!
|
||||||
|
//! **Fail closed.** Everything but an explicit allow denies:
|
||||||
|
//! - a registered marker whose script is missing/disabled → deny;
|
||||||
|
//! - a registered marker with no runnable engine back-reference → deny;
|
||||||
|
//! - a return value that is not `#{ allowed: true }` → deny.
|
||||||
|
//!
|
||||||
|
//! Only a total absence of any marker allows without running anything.
|
||||||
|
//!
|
||||||
|
//! **Re-entrancy.** An interceptor that itself performs the guarded op (e.g.
|
||||||
|
//! an "allow + audit-log" hook that writes KV) must not re-trigger itself. A
|
||||||
|
//! thread-local guard, set for the duration of the interceptor's synchronous
|
||||||
|
//! execution, makes any nested op the interceptor performs bypass interception.
|
||||||
|
|
||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use picloud_shared::{
|
||||||
|
AppId, InterceptorChain, InterceptorEntry, InterceptorService, ScriptId, SdkCallCx,
|
||||||
|
};
|
||||||
|
use rhai::EvalAltResult;
|
||||||
|
use serde_json::{json, Value as Json};
|
||||||
|
use tokio::runtime::Handle as TokioHandle;
|
||||||
|
|
||||||
|
use crate::engine::Engine;
|
||||||
|
use crate::sandbox::Limits;
|
||||||
|
use crate::sdk::bridge::runtime_err;
|
||||||
|
use crate::sdk::invoke::run_resolved_blocking_with_timeout;
|
||||||
|
|
||||||
|
/// Default per-interceptor wall-clock timeout when a marker sets no `timeout_ms`
|
||||||
|
/// (§9.4 M5). Read once from `PICLOUD_INTERCEPTOR_TIMEOUT_MS`; a runaway guard
|
||||||
|
/// (`loop {}`) is denied once this elapses rather than hanging the write path.
|
||||||
|
static DEFAULT_TIMEOUT_MS: LazyLock<u32> = LazyLock::new(|| {
|
||||||
|
std::env::var("PICLOUD_INTERCEPTOR_TIMEOUT_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<u32>().ok())
|
||||||
|
.filter(|&v| v > 0)
|
||||||
|
.unwrap_or(5000)
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Clone-cheap bundle of the §9.4 interceptor dependencies threaded into every
|
||||||
|
/// SDK service `register` fn (M1). Carries the resolver, the `invoke()` re-entry
|
||||||
|
/// engine back-reference, and the sandbox limits — everything `run_before`
|
||||||
|
/// (and, from M3, `run_after`) needs. A few `Arc`s plus a small `Limits` copy.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct InterceptorCtx {
|
||||||
|
pub interceptors: Arc<dyn InterceptorService>,
|
||||||
|
pub self_engine: Option<Arc<Engine>>,
|
||||||
|
pub limits: Limits,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// Re-entrancy depth: `> 0` while an interceptor script (and anything it
|
||||||
|
/// synchronously invokes) is executing on this thread. The whole re-entry
|
||||||
|
/// runs synchronously in the caller's `spawn_blocking` thread (same model
|
||||||
|
/// as `invoke()`), so a thread-local is sufficient and correct.
|
||||||
|
static IN_INTERCEPTOR: Cell<u32> = const { Cell::new(0) };
|
||||||
|
|
||||||
|
/// Identity cycle guard (M2): the `script_id`s of the interceptors currently
|
||||||
|
/// on the synchronous run stack. A chain that would run an interceptor whose
|
||||||
|
/// script is already executing is a cycle — we DENY rather than recurse.
|
||||||
|
/// Precise (keyed by script id) and independent of the binary
|
||||||
|
/// `IN_INTERCEPTOR` counter, which suppresses interception of an
|
||||||
|
/// interceptor's OWN nested writes but does not track identity.
|
||||||
|
static VISITED: RefCell<Vec<ScriptId>> = const { RefCell::new(Vec::new()) };
|
||||||
|
|
||||||
|
/// §9.4 M6: per-execution resolve cache. Keyed by `(app_id, service, op)` —
|
||||||
|
/// the chain a `(service, op)` resolves to is constant for one app across an
|
||||||
|
/// execution tree, so the FIRST hooked-eligible op of that kind pays the one
|
||||||
|
/// chain query and every later op reuses it. The dominant win is the
|
||||||
|
/// un-hooked case: an empty chain is cached once, so N `kv::set`s in a script
|
||||||
|
/// with no interceptors issue ONE resolve, not N. Cleared at the outermost
|
||||||
|
/// execution boundary so a pooled thread never serves a stale (or foreign)
|
||||||
|
/// app's cache.
|
||||||
|
static RESOLVE_CACHE: RefCell<HashMap<(AppId, String, String), InterceptorChain>> =
|
||||||
|
RefCell::new(HashMap::new());
|
||||||
|
/// Scope nesting depth for the resolve cache (mirrors the emission budget):
|
||||||
|
/// `0` when no execution is active on this thread.
|
||||||
|
static CACHE_ACTIVE: Cell<u32> = const { Cell::new(0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RAII scope wrapping one `execute_ast` for the §9.4 M6 resolve cache.
|
||||||
|
/// Re-entrancy aware: the OUTERMOST scope on a thread clears the cache on entry
|
||||||
|
/// and on final exit; a nested synchronous re-entry (invoke / interceptor)
|
||||||
|
/// shares the same cache, so a whole execution tree resolves each `(service,
|
||||||
|
/// op)` at most once.
|
||||||
|
pub(crate) struct InterceptorCacheScope;
|
||||||
|
|
||||||
|
impl InterceptorCacheScope {
|
||||||
|
pub(crate) fn enter() -> Self {
|
||||||
|
CACHE_ACTIVE.with(|a| {
|
||||||
|
if a.get() == 0 {
|
||||||
|
RESOLVE_CACHE.with(|c| c.borrow_mut().clear());
|
||||||
|
}
|
||||||
|
a.set(a.get() + 1);
|
||||||
|
});
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for InterceptorCacheScope {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
CACHE_ACTIVE.with(|a| {
|
||||||
|
let n = a.get().saturating_sub(1);
|
||||||
|
a.set(n);
|
||||||
|
if n == 0 {
|
||||||
|
RESOLVE_CACHE.with(|c| c.borrow_mut().clear());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn currently_in_interceptor() -> bool {
|
||||||
|
IN_INTERCEPTOR.with(|c| c.get() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RAII: increment the re-entrancy counter while an interceptor runs, decrement
|
||||||
|
/// on drop (including on the `?`/panic-unwind paths).
|
||||||
|
struct ReentryGuard;
|
||||||
|
impl ReentryGuard {
|
||||||
|
fn enter() -> Self {
|
||||||
|
IN_INTERCEPTOR.with(|c| c.set(c.get().saturating_add(1)));
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for ReentryGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
IN_INTERCEPTOR.with(|c| c.set(c.get().saturating_sub(1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RAII for the identity cycle guard: push a `script_id` on enter, pop on drop
|
||||||
|
/// (including the `?`/panic-unwind paths), so the visited set exactly tracks the
|
||||||
|
/// interceptors on the current synchronous run stack.
|
||||||
|
struct CycleGuard;
|
||||||
|
impl CycleGuard {
|
||||||
|
fn enter(id: ScriptId) -> Self {
|
||||||
|
VISITED.with(|v| v.borrow_mut().push(id));
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
fn contains(id: ScriptId) -> bool {
|
||||||
|
VISITED.with(|v| v.borrow().contains(&id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for CycleGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
VISITED.with(|v| {
|
||||||
|
v.borrow_mut().pop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parsed outcome of ONE allowing hook: the operation passes, optionally
|
||||||
|
/// carrying a `data` transform (M4) the caller writes instead of the original.
|
||||||
|
struct HookAllow {
|
||||||
|
/// `Some` iff the hook returned a `data` key alongside `allowed: true`.
|
||||||
|
data: Option<Json>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the before-op interceptor CHAIN for `(service, op)` if any are
|
||||||
|
/// registered. Returns `Ok(Some(value))` when a before-hook rewrote the payload
|
||||||
|
/// via a `data` transform (M4) — the caller writes THAT instead of the original
|
||||||
|
/// — or `Ok(None)` to write the original unchanged (un-hooked, or every hook
|
||||||
|
/// allowed without transforming). An `Err` denies the operation (the caller
|
||||||
|
/// must NOT perform the write); the FIRST deny short-circuits. `value` is the
|
||||||
|
/// original payload (`None` for delete, which never transforms).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn run_before(
|
||||||
|
ictx: &InterceptorCtx,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
service: &'static str,
|
||||||
|
op: &'static str,
|
||||||
|
collection: &str,
|
||||||
|
key: &str,
|
||||||
|
value: Option<&Json>,
|
||||||
|
) -> Result<Option<Json>, Box<EvalAltResult>> {
|
||||||
|
// Re-entrancy break: writes performed BY an interceptor (or anything it
|
||||||
|
// invokes) are not themselves intercepted — otherwise an "allow + write"
|
||||||
|
// hook would recurse to the depth cap and deny the original op.
|
||||||
|
if currently_in_interceptor() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let chain = resolve_chain(ictx, cx, service, op)?;
|
||||||
|
if chain.before.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread the (possibly rewritten) value through the chain: each before-hook
|
||||||
|
// sees the prior hook's transform in `value`, and the write uses the final.
|
||||||
|
let mut current: Option<Json> = value.cloned();
|
||||||
|
let mut transformed = false;
|
||||||
|
for entry in &chain.before {
|
||||||
|
let payload = op_payload(service, op, collection, key, current.as_ref(), cx, None);
|
||||||
|
let outcome = run_one_hook(ictx, cx, entry, service, op, &payload)?;
|
||||||
|
// M4 data-transform: honored ONLY on an allowing hook (the verdict parse
|
||||||
|
// already enforced fail-closed). Delete has no value to rewrite.
|
||||||
|
if let Some(data) = outcome.data {
|
||||||
|
if value.is_some() {
|
||||||
|
enforce_transform_size(service, op, &data)?;
|
||||||
|
current = Some(data);
|
||||||
|
transformed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(transformed.then(|| current.unwrap_or(Json::Null)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the after-op interceptor CHAIN (M3) once the write has committed.
|
||||||
|
/// `result` is the write's outcome (serialized), surfaced to the hook so it can
|
||||||
|
/// observe/audit. After-hooks CANNOT roll back — the write already happened; a
|
||||||
|
/// deny here surfaces as an operation error to the caller but the write
|
||||||
|
/// persists. Returns `Ok(())` when every after-hook allowed (or none exist), an
|
||||||
|
/// `Err` on the first deny.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn run_after(
|
||||||
|
ictx: &InterceptorCtx,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
service: &'static str,
|
||||||
|
op: &'static str,
|
||||||
|
collection: &str,
|
||||||
|
key: &str,
|
||||||
|
value: Option<&Json>,
|
||||||
|
result: Json,
|
||||||
|
) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
if currently_in_interceptor() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let chain = resolve_chain(ictx, cx, service, op)?;
|
||||||
|
if chain.after.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for entry in &chain.after {
|
||||||
|
let payload = op_payload(service, op, collection, key, value, cx, Some(&result));
|
||||||
|
// After-hooks observe/deny; a returned `data` is ignored (the write is
|
||||||
|
// already durable, so there is nothing to rewrite for the kv slice).
|
||||||
|
run_one_hook(ictx, cx, entry, service, op, &payload)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the before+after chain for `(service, op)` — every marker visible on
|
||||||
|
/// the calling app's chain, ordered per phase. Memoized per execution tree
|
||||||
|
/// (§9.4 M6): the FIRST call for a `(app_id, service, op)` pays the chain query;
|
||||||
|
/// later calls reuse the cached result. An `Err` (no tokio runtime, or a backend
|
||||||
|
/// failure) makes the caller fail closed rather than silently allow.
|
||||||
|
fn resolve_chain(
|
||||||
|
ictx: &InterceptorCtx,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
service: &'static str,
|
||||||
|
op: &'static str,
|
||||||
|
) -> Result<InterceptorChain, Box<EvalAltResult>> {
|
||||||
|
let cache_key = (cx.app_id, service.to_string(), op.to_string());
|
||||||
|
if let Some(hit) = RESOLVE_CACHE.with(|c| c.borrow().get(&cache_key).cloned()) {
|
||||||
|
return Ok(hit);
|
||||||
|
}
|
||||||
|
let handle = TokioHandle::try_current()
|
||||||
|
.map_err(|e| runtime_err(&format!("{service} interceptor: no tokio runtime: {e}")))?;
|
||||||
|
let interceptors = ictx.interceptors.clone();
|
||||||
|
let resolve_cx = cx.clone();
|
||||||
|
let chain = handle
|
||||||
|
.block_on(async move { interceptors.resolve(&resolve_cx, service, op).await })
|
||||||
|
.map_err(|e| runtime_err(&format!("{service}::{op} interceptor resolve: {e}")))?;
|
||||||
|
RESOLVE_CACHE.with(|c| c.borrow_mut().insert(cache_key, chain.clone()));
|
||||||
|
Ok(chain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the operation-context payload handed to a hook. `result` is `Some` only
|
||||||
|
/// for an after-hook (the write's outcome).
|
||||||
|
fn op_payload(
|
||||||
|
service: &str,
|
||||||
|
op: &str,
|
||||||
|
collection: &str,
|
||||||
|
key: &str,
|
||||||
|
value: Option<&Json>,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
result: Option<&Json>,
|
||||||
|
) -> Json {
|
||||||
|
let mut m = json!({
|
||||||
|
"service": service,
|
||||||
|
"action": op,
|
||||||
|
"collection": collection,
|
||||||
|
"key": key,
|
||||||
|
"value": value,
|
||||||
|
"caller_script_id": cx.script_id.to_string(),
|
||||||
|
"caller_execution_id": cx.execution_id.to_string(),
|
||||||
|
});
|
||||||
|
if let (Json::Object(obj), Some(r)) = (&mut m, result) {
|
||||||
|
obj.insert("result".to_string(), r.clone());
|
||||||
|
}
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run ONE chain entry: fail-closed on a dangling marker, an identity cycle, a
|
||||||
|
/// missing engine back-reference, or a depth-limit breach; otherwise run the
|
||||||
|
/// sealed script and parse its verdict. Returns the allow outcome (with any
|
||||||
|
/// `data` transform) or an `Err` denying the operation.
|
||||||
|
fn run_one_hook(
|
||||||
|
ictx: &InterceptorCtx,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
entry: &InterceptorEntry,
|
||||||
|
service: &str,
|
||||||
|
op: &str,
|
||||||
|
payload: &Json,
|
||||||
|
) -> Result<HookAllow, Box<EvalAltResult>> {
|
||||||
|
let resolved = match entry {
|
||||||
|
// A guard is declared but its script is missing/disabled — fail closed.
|
||||||
|
InterceptorEntry::Dangling(name) => {
|
||||||
|
return Err(runtime_err(&format!(
|
||||||
|
"{service}::{op} denied: interceptor script `{name}` is missing or disabled"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
InterceptorEntry::Run(r) => r,
|
||||||
|
};
|
||||||
|
let script = &resolved.script;
|
||||||
|
|
||||||
|
// Identity cycle guard: if this interceptor's script is already on the run
|
||||||
|
// stack, running it again would recurse forever — DENY.
|
||||||
|
if CycleGuard::contains(script.script_id) {
|
||||||
|
return Err(runtime_err(&format!(
|
||||||
|
"{service}::{op} denied: interceptor cycle detected: `{}`",
|
||||||
|
script.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A guard is registered but the engine back-reference isn't installed (a bare
|
||||||
|
// test engine without `set_self_weak`): we cannot run it, so DENY — a
|
||||||
|
// resolvable guard we can't execute must not fail open. Production always
|
||||||
|
// installs the back-ref.
|
||||||
|
let Some(self_engine) = ictx.self_engine.as_ref() else {
|
||||||
|
return Err(runtime_err(&format!(
|
||||||
|
"{service}::{op} denied: interceptor `{}` cannot run (engine back-reference not installed)",
|
||||||
|
script.name
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Depth bound (shared with invoke / trigger fan-out).
|
||||||
|
if cx.trigger_depth + 1 > ictx.limits.trigger_depth_max {
|
||||||
|
return Err(runtime_err(&format!(
|
||||||
|
"{service}::{op} interceptor `{}`: depth limit exceeded (max {})",
|
||||||
|
script.name, ictx.limits.trigger_depth_max
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the sealed script with the operation context as its request body, under
|
||||||
|
// the re-entrancy guard (its own writes bypass interception), the identity
|
||||||
|
// cycle guard (its own script id is on the stack), and a per-hook timeout
|
||||||
|
// (§9.4 M5 — the marker's `timeout_ms` or the env default, tightened to at
|
||||||
|
// most the caller's remaining deadline). A runaway guard is denied, not hung.
|
||||||
|
let timeout = Duration::from_millis(u64::from(
|
||||||
|
resolved.timeout_ms.unwrap_or(*DEFAULT_TIMEOUT_MS),
|
||||||
|
));
|
||||||
|
let ret = {
|
||||||
|
let _reentry = ReentryGuard::enter();
|
||||||
|
let _cycle = CycleGuard::enter(script.script_id);
|
||||||
|
run_resolved_blocking_with_timeout(
|
||||||
|
self_engine,
|
||||||
|
cx,
|
||||||
|
script,
|
||||||
|
payload.clone(),
|
||||||
|
&format!("{service}::{op} interceptor `{}`", script.name),
|
||||||
|
Some(timeout),
|
||||||
|
)?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fail-closed verdict: allow ONLY on an explicit `#{ allowed: true }`. A bare
|
||||||
|
// bool, a typo'd key, a non-bool `allowed`, a bare unit, or any other shape
|
||||||
|
// DENIES — a control whose job is to deny must not allow by omission.
|
||||||
|
match ret {
|
||||||
|
Json::Object(mut m) if m.get("allowed") == Some(&Json::Bool(true)) => Ok(HookAllow {
|
||||||
|
data: m.remove("data"),
|
||||||
|
}),
|
||||||
|
Json::Object(m) => {
|
||||||
|
let reason = m
|
||||||
|
.get("reason")
|
||||||
|
.and_then(Json::as_str)
|
||||||
|
.unwrap_or("denied by interceptor");
|
||||||
|
Err(runtime_err(&format!(
|
||||||
|
"{service}::{op} denied by interceptor `{}`: {reason}",
|
||||||
|
script.name
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
_ => Err(runtime_err(&format!(
|
||||||
|
"{service}::{op} denied by interceptor `{}`: no allow verdict returned",
|
||||||
|
script.name
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cap a `data` transform at the same byte ceiling the SDK uses for a Rhai→JSON
|
||||||
|
/// materialization, so an interceptor can't rewrite a small write into an
|
||||||
|
/// unbounded blob before it reaches the service's own value cap.
|
||||||
|
fn enforce_transform_size(service: &str, op: &str, data: &Json) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let len = serde_json::to_vec(data)
|
||||||
|
.map(|v| v.len())
|
||||||
|
.unwrap_or(usize::MAX);
|
||||||
|
if len > crate::sdk::bridge::MAX_JSON_MATERIALIZE_BYTES {
|
||||||
|
return Err(runtime_err(&format!(
|
||||||
|
"{service}::{op} interceptor transform too large ({len} bytes exceeds the {}-byte limit)",
|
||||||
|
crate::sdk::bridge::MAX_JSON_MATERIALIZE_BYTES
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ use tokio::runtime::Handle as TokioHandle;
|
|||||||
|
|
||||||
use crate::engine::Engine;
|
use crate::engine::Engine;
|
||||||
use crate::sandbox::Limits;
|
use crate::sandbox::Limits;
|
||||||
use crate::sdk::bridge::json_to_dynamic;
|
use crate::sdk::bridge::{json_to_dynamic, MAX_JSON_MATERIALIZE_BYTES};
|
||||||
use crate::types::{ExecRequest, InvocationType};
|
use crate::types::{ExecRequest, InvocationType};
|
||||||
|
|
||||||
pub(super) fn register(
|
pub(super) fn register(
|
||||||
@@ -85,6 +85,9 @@ pub(super) fn register(
|
|||||||
module.set_native_fn(
|
module.set_native_fn(
|
||||||
"invoke_async",
|
"invoke_async",
|
||||||
move |target: Dynamic, args: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
move |target: Dynamic, args: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
// Audit #2: count this durable emission against the per-execution
|
||||||
|
// fan-out ceiling before doing any work.
|
||||||
|
crate::sdk::emit_budget::charge_emission("invoke_async")?;
|
||||||
let target = parse_target(target)?;
|
let target = parse_target(target)?;
|
||||||
let args_json = args_to_json(&args)?;
|
let args_json = args_to_json(&args)?;
|
||||||
let svc = svc.clone();
|
let svc = svc.clone();
|
||||||
@@ -163,9 +166,69 @@ fn invoke_blocking(
|
|||||||
.into()
|
.into()
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let execution_id = ExecutionId::new();
|
// The callee's return is the response `body` JSON. Convert back to
|
||||||
|
// Dynamic for the caller. Status code + headers are dropped; the
|
||||||
|
// function-call mental model is "return value", not HTTP response.
|
||||||
|
let body = run_resolved_blocking(self_engine, cx, &resolved, args_json, &target_label)?;
|
||||||
|
Ok(json_to_dynamic(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronous same-engine re-entry: build the callee `ExecRequest` (inheriting
|
||||||
|
/// the caller's app/principal/root/depth+1 and the callee's lexical owner),
|
||||||
|
/// compile through the per-Engine AST cache (F-P-004), execute, and return the
|
||||||
|
/// response `body` JSON. Shared by `invoke()` and the §9.4 interceptor hook —
|
||||||
|
/// the caller is responsible for the depth check (both do it before resolving).
|
||||||
|
/// `label` prefixes any compile/execute error.
|
||||||
|
/// Run a resolved script through the `invoke()` re-entry core, inheriting the
|
||||||
|
/// caller's ambient execution deadline.
|
||||||
|
pub(super) fn run_resolved_blocking(
|
||||||
|
self_engine: &Arc<Engine>,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
resolved: &picloud_shared::ResolvedScript,
|
||||||
|
body_json: Json,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<Json, Box<EvalAltResult>> {
|
||||||
|
run_resolved_core(
|
||||||
|
self_engine,
|
||||||
|
cx,
|
||||||
|
resolved,
|
||||||
|
body_json,
|
||||||
|
label,
|
||||||
|
crate::engine::ambient_deadline(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`run_resolved_blocking`] but bounds the run by a per-hook `timeout`
|
||||||
|
/// (§9.4 M5). The effective deadline is `min(ambient, now + timeout)` — a hook
|
||||||
|
/// can only ever TIGHTEN, never extend, its caller's deadline.
|
||||||
|
pub(super) fn run_resolved_blocking_with_timeout(
|
||||||
|
self_engine: &Arc<Engine>,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
resolved: &picloud_shared::ResolvedScript,
|
||||||
|
body_json: Json,
|
||||||
|
label: &str,
|
||||||
|
timeout: Option<std::time::Duration>,
|
||||||
|
) -> Result<Json, Box<EvalAltResult>> {
|
||||||
|
let ambient = crate::engine::ambient_deadline();
|
||||||
|
let own = timeout.map(|d| std::time::Instant::now() + d);
|
||||||
|
let effective = match (ambient, own) {
|
||||||
|
(Some(a), Some(o)) => Some(a.min(o)),
|
||||||
|
(a, None) => a,
|
||||||
|
(None, o) => o,
|
||||||
|
};
|
||||||
|
run_resolved_core(self_engine, cx, resolved, body_json, label, effective)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_resolved_core(
|
||||||
|
self_engine: &Arc<Engine>,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
resolved: &picloud_shared::ResolvedScript,
|
||||||
|
body_json: Json,
|
||||||
|
label: &str,
|
||||||
|
deadline: Option<std::time::Instant>,
|
||||||
|
) -> Result<Json, Box<EvalAltResult>> {
|
||||||
let req = ExecRequest {
|
let req = ExecRequest {
|
||||||
execution_id,
|
execution_id: ExecutionId::new(),
|
||||||
request_id: cx.request_id,
|
request_id: cx.request_id,
|
||||||
script_id: resolved.script_id,
|
script_id: resolved.script_id,
|
||||||
script_name: resolved.name.clone(),
|
script_name: resolved.name.clone(),
|
||||||
@@ -173,48 +236,34 @@ fn invoke_blocking(
|
|||||||
path: "/invoke".into(),
|
path: "/invoke".into(),
|
||||||
method: String::new(),
|
method: String::new(),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
body: args_json,
|
body: body_json,
|
||||||
params: BTreeMap::new(),
|
params: BTreeMap::new(),
|
||||||
query: BTreeMap::new(),
|
query: BTreeMap::new(),
|
||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
|
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
|
||||||
app_id: cx.app_id,
|
app_id: cx.app_id,
|
||||||
// Same-app invoke is a function call, not a re-auth boundary —
|
// Lexical origin (§5.5): the callee's defining node — a group
|
||||||
// inherit the caller's principal.
|
// script's imports resolve from the group even when invoked by an
|
||||||
|
// app. `None` falls back to `App(cx.app_id)` in the engine.
|
||||||
|
script_owner: resolved.owner,
|
||||||
|
// Same-app re-entry is not a re-auth boundary — inherit the principal.
|
||||||
principal: cx.principal.clone(),
|
principal: cx.principal.clone(),
|
||||||
trigger_depth: cx.trigger_depth + 1,
|
trigger_depth: cx.trigger_depth + 1,
|
||||||
root_execution_id: cx.root_execution_id,
|
root_execution_id: cx.root_execution_id,
|
||||||
is_dead_letter_handler: cx.is_dead_letter_handler,
|
is_dead_letter_handler: cx.is_dead_letter_handler,
|
||||||
event: None,
|
event: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// F-P-004: synchronous re-entry — route through the per-Engine
|
|
||||||
// AST cache so each callee parses once per (script_id, updated_at),
|
|
||||||
// not once per invoke. Composed workflows multiply parse cost by
|
|
||||||
// depth; the cache cuts that to constant compile + N executions.
|
|
||||||
let ast = self_engine
|
let ast = self_engine
|
||||||
.compile_for_identity(resolved.script_id, resolved.updated_at, &resolved.source)
|
.compile_for_identity(resolved.script_id, resolved.updated_at, &resolved.source)
|
||||||
.map_err(|e| -> Box<EvalAltResult> {
|
.map_err(|e| -> Box<EvalAltResult> {
|
||||||
EvalAltResult::ErrorRuntime(
|
EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into()
|
||||||
format!("invoke({target_label}): {e}").into(),
|
|
||||||
rhai::Position::NONE,
|
|
||||||
)
|
|
||||||
.into()
|
|
||||||
})?;
|
})?;
|
||||||
let resp = self_engine
|
let resp = self_engine
|
||||||
.execute_ast(&ast, req)
|
.execute_ast_with_deadline(&ast, req, deadline)
|
||||||
.map_err(|e| -> Box<EvalAltResult> {
|
.map_err(|e| -> Box<EvalAltResult> {
|
||||||
EvalAltResult::ErrorRuntime(
|
EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into()
|
||||||
format!("invoke({target_label}): {e}").into(),
|
|
||||||
rhai::Position::NONE,
|
|
||||||
)
|
|
||||||
.into()
|
|
||||||
})?;
|
})?;
|
||||||
|
Ok(resp.body)
|
||||||
// The callee's return is the response `body` JSON. Convert back to
|
|
||||||
// Dynamic for the caller. Status code + headers are dropped; the
|
|
||||||
// function-call mental model is "return value", not HTTP response.
|
|
||||||
Ok(json_to_dynamic(resp.body))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accept a string (route path OR script name) or a Rhai script-id
|
/// Accept a string (route path OR script name) or a Rhai script-id
|
||||||
@@ -243,8 +292,35 @@ fn parse_target(target: Dynamic) -> Result<InvokeTarget, Box<EvalAltResult>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convert Rhai Dynamic → JSON. Rejects FnPtr at any depth (closures
|
/// Convert Rhai Dynamic → JSON. Rejects FnPtr at any depth (closures
|
||||||
/// don't survive invoke boundaries).
|
/// don't survive invoke boundaries) and is bounded by
|
||||||
|
/// [`MAX_JSON_MATERIALIZE_BYTES`] so a cheaply-aliased huge args value can't OOM
|
||||||
|
/// the node on materialization (same anti-OOM rail as `dynamic_to_json_capped`).
|
||||||
fn args_to_json(value: &Dynamic) -> Result<Json, Box<EvalAltResult>> {
|
fn args_to_json(value: &Dynamic) -> Result<Json, Box<EvalAltResult>> {
|
||||||
|
let mut remaining = MAX_JSON_MATERIALIZE_BYTES;
|
||||||
|
args_to_json_bounded(value, &mut remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn args_charge(remaining: &mut usize, n: usize) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
match remaining.checked_sub(n) {
|
||||||
|
Some(left) => {
|
||||||
|
*remaining = left;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(EvalAltResult::ErrorRuntime(
|
||||||
|
format!(
|
||||||
|
"invoke: args too large to serialize (exceeds the {MAX_JSON_MATERIALIZE_BYTES}-byte limit)"
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn args_to_json_bounded(
|
||||||
|
value: &Dynamic,
|
||||||
|
remaining: &mut usize,
|
||||||
|
) -> Result<Json, Box<EvalAltResult>> {
|
||||||
if value.is::<rhai::FnPtr>() {
|
if value.is::<rhai::FnPtr>() {
|
||||||
return Err(EvalAltResult::ErrorRuntime(
|
return Err(EvalAltResult::ErrorRuntime(
|
||||||
"invoke: args must not contain FnPtr / closures".into(),
|
"invoke: args must not contain FnPtr / closures".into(),
|
||||||
@@ -254,40 +330,52 @@ fn args_to_json(value: &Dynamic) -> Result<Json, Box<EvalAltResult>> {
|
|||||||
}
|
}
|
||||||
if value.is_blob() {
|
if value.is_blob() {
|
||||||
let blob = value.clone().into_blob().unwrap_or_default();
|
let blob = value.clone().into_blob().unwrap_or_default();
|
||||||
return Ok(Json::String(STANDARD.encode(&blob)));
|
let encoded = STANDARD.encode(&blob);
|
||||||
|
args_charge(remaining, encoded.len() + 2)?;
|
||||||
|
return Ok(Json::String(encoded));
|
||||||
}
|
}
|
||||||
if value.is_unit() {
|
if value.is_unit() {
|
||||||
|
args_charge(remaining, 4)?;
|
||||||
return Ok(Json::Null);
|
return Ok(Json::Null);
|
||||||
}
|
}
|
||||||
if let Ok(b) = value.as_bool() {
|
if let Ok(b) = value.as_bool() {
|
||||||
|
args_charge(remaining, 5)?;
|
||||||
return Ok(Json::Bool(b));
|
return Ok(Json::Bool(b));
|
||||||
}
|
}
|
||||||
if let Ok(i) = value.as_int() {
|
if let Ok(i) = value.as_int() {
|
||||||
|
args_charge(remaining, 8)?;
|
||||||
return Ok(Json::Number(i.into()));
|
return Ok(Json::Number(i.into()));
|
||||||
}
|
}
|
||||||
if let Ok(f) = value.as_float() {
|
if let Ok(f) = value.as_float() {
|
||||||
|
args_charge(remaining, 8)?;
|
||||||
return Ok(serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number));
|
return Ok(serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number));
|
||||||
}
|
}
|
||||||
if value.is_string() {
|
if value.is_string() {
|
||||||
return Ok(Json::String(
|
let s = value.clone().into_string().unwrap_or_default();
|
||||||
value.clone().into_string().unwrap_or_default(),
|
args_charge(remaining, s.len() + 2)?;
|
||||||
));
|
return Ok(Json::String(s));
|
||||||
}
|
}
|
||||||
if let Some(arr) = value.clone().try_cast::<Array>() {
|
if let Some(arr) = value.clone().try_cast::<Array>() {
|
||||||
let mut out = Vec::with_capacity(arr.len());
|
args_charge(remaining, 2)?;
|
||||||
|
let mut out = Vec::with_capacity(arr.len().min(1024));
|
||||||
for v in &arr {
|
for v in &arr {
|
||||||
out.push(args_to_json(v)?);
|
args_charge(remaining, 1)?;
|
||||||
|
out.push(args_to_json_bounded(v, remaining)?);
|
||||||
}
|
}
|
||||||
return Ok(Json::Array(out));
|
return Ok(Json::Array(out));
|
||||||
}
|
}
|
||||||
if let Some(map) = value.clone().try_cast::<Map>() {
|
if let Some(map) = value.clone().try_cast::<Map>() {
|
||||||
|
args_charge(remaining, 2)?;
|
||||||
let mut out = serde_json::Map::new();
|
let mut out = serde_json::Map::new();
|
||||||
for (k, v) in map {
|
for (k, v) in map {
|
||||||
out.insert(k.to_string(), args_to_json(&v)?);
|
args_charge(remaining, k.len() + 4)?;
|
||||||
|
out.insert(k.to_string(), args_to_json_bounded(&v, remaining)?);
|
||||||
}
|
}
|
||||||
return Ok(Json::Object(out));
|
return Ok(Json::Object(out));
|
||||||
}
|
}
|
||||||
Ok(Json::String(value.to_string()))
|
let s = value.to_string();
|
||||||
|
args_charge(remaining, s.len() + 2)?;
|
||||||
|
Ok(Json::String(s))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `Limits` is `Copy`; passed by value into `register` so closures take
|
/// `Limits` is `Copy`; passed by value into `register` so closures take
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
//! widgets.set("k", #{ n: 1 });
|
//! widgets.set("k", #{ n: 1 });
|
||||||
//! let v = widgets.get("k"); // value or () if absent
|
//! let v = widgets.get("k"); // value or () if absent
|
||||||
//! if widgets.has("k") { ... }
|
//! if widgets.has("k") { ... }
|
||||||
|
//! widgets.set_if("k", (), #{ n: 0 }); // insert only if ABSENT -> bool
|
||||||
|
//! widgets.set_if("k", old, new); // swap only if current == old -> bool
|
||||||
//! widgets.delete("k"); // bool (was-present)
|
//! widgets.delete("k"); // bool (was-present)
|
||||||
//! let page = widgets.list(); // returns #{ keys: [...], next_cursor: () }
|
//! let page = widgets.list(); // returns #{ keys: [...], next_cursor: () }
|
||||||
//! ```
|
//! ```
|
||||||
@@ -28,29 +30,57 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use picloud_shared::{KvService, SdkCallCx, Services};
|
use picloud_shared::{GroupKvService, KvService, SdkCallCx, Services};
|
||||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
|
|
||||||
use super::bridge::{block_on, dynamic_to_json, json_to_dynamic};
|
use super::bridge::{
|
||||||
|
block_on, dynamic_to_json_capped, json_to_dynamic, JsonSizeError, MAX_JSON_MATERIALIZE_BYTES,
|
||||||
|
};
|
||||||
|
use super::interceptor::InterceptorCtx;
|
||||||
|
|
||||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
/// Per-call handle captured by the Rhai SDK. Cheap to clone (a few Arcs plus an
|
||||||
/// plus an owned string).
|
/// owned string). Carries the §9.4 interceptor ctx so `set`/`delete` can run a
|
||||||
|
/// before-op allow/deny hook (the resolver + the `invoke()` re-entry engine).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct KvHandle {
|
pub struct KvHandle {
|
||||||
collection: String,
|
collection: String,
|
||||||
service: Arc<dyn KvService>,
|
service: Arc<dyn KvService>,
|
||||||
cx: Arc<SdkCallCx>,
|
cx: Arc<SdkCallCx>,
|
||||||
|
ictx: InterceptorCtx,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
/// §11.6 shared-collection handle, returned by `kv::shared_collection(name)`. A distinct
|
||||||
let kv_service = services.kv.clone();
|
/// Rhai type from `KvHandle` so the method set can diverge (e.g. shared
|
||||||
|
/// collections never grow triggers) and a script's choice of private-vs-shared
|
||||||
|
/// scope is explicit. Routes through the `GroupKvService`, which resolves the
|
||||||
|
/// owning group from `cx.app_id` (the script never names a group). Carries the
|
||||||
|
/// §9.4 interceptor ctx too so a `(kv, set/delete)` guard covers shared-
|
||||||
|
/// collection writes — otherwise the shared handle would be a silent bypass.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GroupKvHandle {
|
||||||
|
collection: String,
|
||||||
|
service: Arc<dyn GroupKvService>,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
ictx: InterceptorCtx,
|
||||||
|
}
|
||||||
|
|
||||||
// `kv::collection(name)` — handle constructor lives in the `kv`
|
pub(super) fn register(
|
||||||
// static module so the script-visible call is `kv::collection(...)`.
|
engine: &mut RhaiEngine,
|
||||||
|
services: &Services,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
ictx: InterceptorCtx,
|
||||||
|
) {
|
||||||
|
let kv_service = services.kv.clone();
|
||||||
|
let group_kv_service = services.group_kv.clone();
|
||||||
|
|
||||||
|
// `kv::collection(name)` / `kv::shared_collection(name)` — both constructors live in
|
||||||
|
// the `kv` static module so the script-visible calls are `kv::collection`
|
||||||
|
// and `kv::shared_collection` (`shared` alone is a Rhai reserved word).
|
||||||
let mut module = Module::new();
|
let mut module = Module::new();
|
||||||
{
|
{
|
||||||
let kv_service = kv_service.clone();
|
let kv_service = kv_service.clone();
|
||||||
let cx = cx.clone();
|
let cx = cx.clone();
|
||||||
|
let ictx = ictx.clone();
|
||||||
module.set_native_fn(
|
module.set_native_fn(
|
||||||
"collection",
|
"collection",
|
||||||
move |name: &str| -> Result<KvHandle, Box<EvalAltResult>> {
|
move |name: &str| -> Result<KvHandle, Box<EvalAltResult>> {
|
||||||
@@ -61,6 +91,26 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
collection: name.to_string(),
|
collection: name.to_string(),
|
||||||
service: kv_service.clone(),
|
service: kv_service.clone(),
|
||||||
cx: cx.clone(),
|
cx: cx.clone(),
|
||||||
|
ictx: ictx.clone(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let group_kv_service = group_kv_service.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let ictx = ictx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"shared_collection",
|
||||||
|
move |name: &str| -> Result<GroupKvHandle, Box<EvalAltResult>> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("kv::shared_collection name must not be empty".into());
|
||||||
|
}
|
||||||
|
Ok(GroupKvHandle {
|
||||||
|
collection: name.to_string(),
|
||||||
|
service: group_kv_service.clone(),
|
||||||
|
cx: cx.clone(),
|
||||||
|
ictx: ictx.clone(),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -74,9 +124,34 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
|
|
||||||
register_get(engine);
|
register_get(engine);
|
||||||
register_set(engine);
|
register_set(engine);
|
||||||
|
register_set_if(engine);
|
||||||
register_has(engine);
|
register_has(engine);
|
||||||
register_delete(engine);
|
register_delete(engine);
|
||||||
register_list(engine);
|
register_list(engine);
|
||||||
|
|
||||||
|
// Same method names on GroupKvHandle — Rhai dispatches by receiver type.
|
||||||
|
engine.register_type_with_name::<GroupKvHandle>("GroupKvHandle");
|
||||||
|
|
||||||
|
register_group_get(engine);
|
||||||
|
register_group_set(engine);
|
||||||
|
register_group_set_if(engine);
|
||||||
|
register_group_has(engine);
|
||||||
|
register_group_delete(engine);
|
||||||
|
register_group_list(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a Rhai `expected` argument to the CAS precondition: Rhai unit `()` means
|
||||||
|
/// "expected ABSENT" (insert-if-absent); any other value is the expected current
|
||||||
|
/// value.
|
||||||
|
fn expected_from_dynamic(expected: &Dynamic) -> Result<Option<serde_json::Value>, JsonSizeError> {
|
||||||
|
if expected.is_unit() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(dynamic_to_json_capped(
|
||||||
|
expected,
|
||||||
|
MAX_JSON_MATERIALIZE_BYTES,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register_get(engine: &mut RhaiEngine) {
|
fn register_get(engine: &mut RhaiEngine) {
|
||||||
@@ -96,11 +171,82 @@ fn register_set(engine: &mut RhaiEngine) {
|
|||||||
engine.register_fn(
|
engine.register_fn(
|
||||||
"set",
|
"set",
|
||||||
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
|
// §9.4 before-op interceptor (allow/deny + M4 data-transform). A
|
||||||
|
// denial errors here; a returned `data` rewrites the written value.
|
||||||
|
let write_val = super::interceptor::run_before(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"kv",
|
||||||
|
"set",
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
Some(&json),
|
||||||
|
)?
|
||||||
|
.unwrap_or(json);
|
||||||
|
let written = write_val.clone();
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let json = dynamic_to_json(&value);
|
|
||||||
block_on("kv", async move {
|
block_on("kv", async move {
|
||||||
h.service.set(&h.cx, &h.collection, key, json).await
|
h.service.set(&h.cx, &h.collection, key, write_val).await
|
||||||
})
|
})?;
|
||||||
|
// §9.4 M3 after-hook (observe/audit; the write already committed).
|
||||||
|
super::interceptor::run_after(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"kv",
|
||||||
|
"set",
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
Some(&written),
|
||||||
|
serde_json::Value::Null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_set_if(engine: &mut RhaiEngine) {
|
||||||
|
// `handle.set_if(key, expected, new)` — CAS. `expected = ()` means "only if
|
||||||
|
// absent". Returns `true` if the swap happened, `false` if the precondition
|
||||||
|
// failed.
|
||||||
|
engine.register_fn(
|
||||||
|
"set_if",
|
||||||
|
|handle: &mut KvHandle,
|
||||||
|
key: &str,
|
||||||
|
expected: Dynamic,
|
||||||
|
new: Dynamic|
|
||||||
|
-> Result<bool, Box<EvalAltResult>> {
|
||||||
|
let exp = expected_from_dynamic(&expected)?;
|
||||||
|
let json = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
|
// §9.4 M12: CAS is a WRITE, so it runs the `(kv, set)` guard too —
|
||||||
|
// otherwise `set_if` would be a silent bypass of a `set` policy.
|
||||||
|
let write_val = super::interceptor::run_before(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"kv",
|
||||||
|
"set",
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
Some(&json),
|
||||||
|
)?
|
||||||
|
.unwrap_or(json);
|
||||||
|
let written = write_val.clone();
|
||||||
|
let h = handle.clone();
|
||||||
|
let swapped = block_on("kv", async move {
|
||||||
|
h.service
|
||||||
|
.set_if(&h.cx, &h.collection, key, exp, write_val)
|
||||||
|
.await
|
||||||
|
})?;
|
||||||
|
super::interceptor::run_after(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"kv",
|
||||||
|
"set",
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
Some(&written),
|
||||||
|
serde_json::Value::Bool(swapped),
|
||||||
|
)?;
|
||||||
|
Ok(swapped)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -121,14 +267,73 @@ fn register_delete(engine: &mut RhaiEngine) {
|
|||||||
engine.register_fn(
|
engine.register_fn(
|
||||||
"delete",
|
"delete",
|
||||||
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
// Delete carries no value, so the before-hook never transforms.
|
||||||
|
super::interceptor::run_before(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"kv",
|
||||||
|
"delete",
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
block_on("kv", async move {
|
let was_present = block_on("kv", async move {
|
||||||
h.service.delete(&h.cx, &h.collection, key).await
|
h.service.delete(&h.cx, &h.collection, key).await
|
||||||
})
|
})?;
|
||||||
|
super::interceptor::run_after(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"kv",
|
||||||
|
"delete",
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
None,
|
||||||
|
serde_json::Value::Bool(was_present),
|
||||||
|
)?;
|
||||||
|
Ok(was_present)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §9.4: run the before-op interceptor for a shared-collection write, so a
|
||||||
|
/// `(kv, set/delete)` guard covers the shared handle too (not just private KV).
|
||||||
|
fn group_run_before(
|
||||||
|
handle: &GroupKvHandle,
|
||||||
|
op: &'static str,
|
||||||
|
key: &str,
|
||||||
|
value: Option<&serde_json::Value>,
|
||||||
|
) -> Result<Option<serde_json::Value>, Box<EvalAltResult>> {
|
||||||
|
super::interceptor::run_before(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"kv",
|
||||||
|
op,
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_run_after(
|
||||||
|
handle: &GroupKvHandle,
|
||||||
|
op: &'static str,
|
||||||
|
key: &str,
|
||||||
|
value: Option<&serde_json::Value>,
|
||||||
|
result: serde_json::Value,
|
||||||
|
) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
super::interceptor::run_after(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"kv",
|
||||||
|
op,
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn register_list(engine: &mut RhaiEngine) {
|
fn register_list(engine: &mut RhaiEngine) {
|
||||||
// Zero-arg form — full page, no cursor.
|
// Zero-arg form — full page, no cursor.
|
||||||
engine.register_fn(
|
engine.register_fn(
|
||||||
@@ -174,3 +379,143 @@ fn list_call(
|
|||||||
);
|
);
|
||||||
Ok(m)
|
Ok(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- GroupKvHandle methods (§11.6 shared collections) ----------------------
|
||||||
|
|
||||||
|
fn register_group_get(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"get",
|
||||||
|
|handle: &mut GroupKvHandle, key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
block_on("kv", async move {
|
||||||
|
h.service.get(&h.cx, &h.collection, key).await
|
||||||
|
})
|
||||||
|
.map(|opt| opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_set(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"set",
|
||||||
|
|handle: &mut GroupKvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
|
// §9.4 before-op interceptor also guards shared-collection writes
|
||||||
|
// (allow/deny + M4 data-transform).
|
||||||
|
let write_val = group_run_before(handle, "set", key, Some(&json))?.unwrap_or(json);
|
||||||
|
let written = write_val.clone();
|
||||||
|
let h = handle.clone();
|
||||||
|
block_on("kv", async move {
|
||||||
|
h.service.set(&h.cx, &h.collection, key, write_val).await
|
||||||
|
})?;
|
||||||
|
group_run_after(handle, "set", key, Some(&written), serde_json::Value::Null)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_set_if(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"set_if",
|
||||||
|
|handle: &mut GroupKvHandle,
|
||||||
|
key: &str,
|
||||||
|
expected: Dynamic,
|
||||||
|
new: Dynamic|
|
||||||
|
-> Result<bool, Box<EvalAltResult>> {
|
||||||
|
let exp = expected_from_dynamic(&expected)?;
|
||||||
|
let json = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
|
// §9.4 M12: CAS on a shared collection runs the `(kv, set)` guard too.
|
||||||
|
let write_val = group_run_before(handle, "set", key, Some(&json))?.unwrap_or(json);
|
||||||
|
let written = write_val.clone();
|
||||||
|
let h = handle.clone();
|
||||||
|
let swapped = block_on("kv", async move {
|
||||||
|
h.service
|
||||||
|
.set_if(&h.cx, &h.collection, key, exp, write_val)
|
||||||
|
.await
|
||||||
|
})?;
|
||||||
|
group_run_after(
|
||||||
|
handle,
|
||||||
|
"set",
|
||||||
|
key,
|
||||||
|
Some(&written),
|
||||||
|
serde_json::Value::Bool(swapped),
|
||||||
|
)?;
|
||||||
|
Ok(swapped)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_has(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"has",
|
||||||
|
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
block_on("kv", async move {
|
||||||
|
h.service.has(&h.cx, &h.collection, key).await
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_delete(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"delete",
|
||||||
|
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
group_run_before(handle, "delete", key, None)?;
|
||||||
|
let h = handle.clone();
|
||||||
|
let was_present = block_on("kv", async move {
|
||||||
|
h.service.delete(&h.cx, &h.collection, key).await
|
||||||
|
})?;
|
||||||
|
group_run_after(
|
||||||
|
handle,
|
||||||
|
"delete",
|
||||||
|
key,
|
||||||
|
None,
|
||||||
|
serde_json::Value::Bool(was_present),
|
||||||
|
)?;
|
||||||
|
Ok(was_present)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_list(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupKvHandle| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
group_list_call(handle, None, 0)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupKvHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
group_list_call(handle, Some(cursor.to_string()), 0)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupKvHandle, cursor: &str, limit: i64| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let limit = u32::try_from(limit.max(0)).unwrap_or(0);
|
||||||
|
group_list_call(handle, Some(cursor.to_string()), limit)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_list_call(
|
||||||
|
handle: &GroupKvHandle,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let page = block_on("kv", async move {
|
||||||
|
h.service
|
||||||
|
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||||
|
.await
|
||||||
|
})?;
|
||||||
|
let mut m = Map::new();
|
||||||
|
let keys: Array = page.keys.into_iter().map(Dynamic::from).collect();
|
||||||
|
m.insert("keys".into(), keys.into());
|
||||||
|
m.insert(
|
||||||
|
"next_cursor".into(),
|
||||||
|
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||||
|
);
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ pub mod cx;
|
|||||||
pub mod dead_letters;
|
pub mod dead_letters;
|
||||||
pub mod docs;
|
pub mod docs;
|
||||||
pub mod email;
|
pub mod email;
|
||||||
|
pub mod emit_budget;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
|
pub mod interceptor;
|
||||||
pub mod invoke;
|
pub mod invoke;
|
||||||
pub mod kv;
|
pub mod kv;
|
||||||
pub mod pubsub;
|
pub mod pubsub;
|
||||||
@@ -26,8 +28,13 @@ pub mod retry;
|
|||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
pub mod stdlib;
|
pub mod stdlib;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
pub mod vars;
|
||||||
|
pub mod workflow;
|
||||||
|
|
||||||
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
pub use bridge::{
|
||||||
|
dynamic_to_json, dynamic_to_json_capped, json_to_dynamic, JsonSizeError,
|
||||||
|
MAX_JSON_MATERIALIZE_BYTES,
|
||||||
|
};
|
||||||
pub use cx::SdkCallCx;
|
pub use cx::SdkCallCx;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -53,16 +60,26 @@ pub fn register_all(
|
|||||||
limits: Limits,
|
limits: Limits,
|
||||||
self_engine: Option<Arc<Engine>>,
|
self_engine: Option<Arc<Engine>>,
|
||||||
) {
|
) {
|
||||||
kv::register(engine, services, cx.clone());
|
// §9.4 M1: build the interceptor ctx once and thread it into every SDK
|
||||||
docs::register(engine, services, cx.clone());
|
// service that has (or will gain) a before/after hook. Only `kv` runs a
|
||||||
|
// hook today; docs/files/queue/pubsub/http carry it for M7–M11.
|
||||||
|
let ictx = interceptor::InterceptorCtx {
|
||||||
|
interceptors: services.interceptors.clone(),
|
||||||
|
self_engine: self_engine.clone(),
|
||||||
|
limits,
|
||||||
|
};
|
||||||
|
kv::register(engine, services, cx.clone(), ictx.clone());
|
||||||
|
docs::register(engine, services, cx.clone(), ictx.clone());
|
||||||
dead_letters::register(engine, services, cx.clone());
|
dead_letters::register(engine, services, cx.clone());
|
||||||
http::register(engine, services, cx.clone());
|
http::register(engine, services, cx.clone(), ictx.clone());
|
||||||
files::register(engine, services, cx.clone());
|
files::register(engine, services, cx.clone(), ictx.clone());
|
||||||
pubsub::register(engine, services, cx.clone());
|
pubsub::register(engine, services, cx.clone(), ictx.clone());
|
||||||
queue::register(engine, services, cx.clone());
|
queue::register(engine, services, cx.clone(), ictx);
|
||||||
retry::register(engine, services, cx.clone());
|
retry::register(engine, services, cx.clone());
|
||||||
secrets::register(engine, services, cx.clone());
|
secrets::register(engine, services, cx.clone());
|
||||||
|
vars::register(engine, services, cx.clone());
|
||||||
email::register(engine, services, cx.clone());
|
email::register(engine, services, cx.clone());
|
||||||
users::register(engine, services, cx.clone());
|
users::register(engine, services, cx.clone());
|
||||||
|
workflow::register(engine, services, cx.clone());
|
||||||
invoke::register(engine, services, cx, limits, self_engine);
|
invoke::register(engine, services, cx, limits, self_engine);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,23 +24,53 @@ use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
|||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
use tokio::runtime::Handle as TokioHandle;
|
use tokio::runtime::Handle as TokioHandle;
|
||||||
|
|
||||||
use super::bridge::block_on;
|
use super::bridge::{block_on, MAX_JSON_MATERIALIZE_BYTES};
|
||||||
|
use super::interceptor::InterceptorCtx;
|
||||||
|
|
||||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
pub(super) fn register(
|
||||||
|
engine: &mut RhaiEngine,
|
||||||
|
services: &Services,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
ictx: InterceptorCtx,
|
||||||
|
) {
|
||||||
let svc = services.pubsub.clone();
|
let svc = services.pubsub.clone();
|
||||||
let mut module = Module::new();
|
let mut module = Module::new();
|
||||||
{
|
{
|
||||||
let svc = svc.clone();
|
let svc = svc.clone();
|
||||||
let cx = cx.clone();
|
let cx = cx.clone();
|
||||||
|
let ictx = ictx.clone();
|
||||||
module.set_native_fn(
|
module.set_native_fn(
|
||||||
"publish_durable",
|
"publish_durable",
|
||||||
move |topic: &str, message: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
move |topic: &str, message: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||||
let json = message_to_json(&message);
|
crate::sdk::emit_budget::charge_emission("pubsub::publish_durable")?;
|
||||||
let svc = svc.clone();
|
let json = message_to_json(&message)?;
|
||||||
let cx = cx.clone();
|
// §9.4 before-op interceptor (allow/deny + M4 data-transform).
|
||||||
|
let write_val = super::interceptor::run_before(
|
||||||
|
&ictx,
|
||||||
|
&cx,
|
||||||
|
"pubsub",
|
||||||
|
"publish",
|
||||||
|
topic,
|
||||||
|
"",
|
||||||
|
Some(&json),
|
||||||
|
)?
|
||||||
|
.unwrap_or(json);
|
||||||
|
let written = write_val.clone();
|
||||||
|
let svc2 = svc.clone();
|
||||||
|
let cx2 = cx.clone();
|
||||||
block_on("pubsub", async move {
|
block_on("pubsub", async move {
|
||||||
svc.publish_durable(&cx, topic, json).await
|
svc2.publish_durable(&cx2, topic, write_val).await
|
||||||
})
|
})?;
|
||||||
|
super::interceptor::run_after(
|
||||||
|
&ictx,
|
||||||
|
&cx,
|
||||||
|
"pubsub",
|
||||||
|
"publish",
|
||||||
|
topic,
|
||||||
|
"",
|
||||||
|
Some(&written),
|
||||||
|
Json::Null,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -69,7 +99,101 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// §11.6 D2: `pubsub::shared_topic("name")` → a handle whose `.publish(...)`
|
||||||
|
// publishes to the group's shared topic namespace (fans out to `shared`
|
||||||
|
// group pubsub triggers on the owning group). The owning group resolves from
|
||||||
|
// `cx.app_id` inside the service — never a script arg.
|
||||||
|
{
|
||||||
|
let group_svc = services.group_pubsub.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let ictx = ictx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"shared_topic",
|
||||||
|
move |name: &str| -> Result<GroupTopicHandle, Box<EvalAltResult>> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("pubsub::shared_topic name must not be empty".into());
|
||||||
|
}
|
||||||
|
Ok(GroupTopicHandle {
|
||||||
|
namespace: name.to_string(),
|
||||||
|
service: group_svc.clone(),
|
||||||
|
cx: cx.clone(),
|
||||||
|
ictx: ictx.clone(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
engine.register_static_module("pubsub", module.into());
|
engine.register_static_module("pubsub", module.into());
|
||||||
|
engine.register_type_with_name::<GroupTopicHandle>("GroupTopicHandle");
|
||||||
|
register_shared_publish(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §11.6 D2 handle returned by `pubsub::shared_topic("name")`. `.publish(sub,
|
||||||
|
/// msg)` publishes `name.sub` to the group shared topic; `.publish(msg)`
|
||||||
|
/// publishes to `name` directly.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GroupTopicHandle {
|
||||||
|
namespace: String,
|
||||||
|
service: Arc<dyn picloud_shared::GroupPubsubService>,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
// §9.4: carried so shared `publish` runs the before/after hook too (M11).
|
||||||
|
ictx: InterceptorCtx,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shared_publish(
|
||||||
|
h: &mut GroupTopicHandle,
|
||||||
|
subtopic: &str,
|
||||||
|
message: Dynamic,
|
||||||
|
) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
crate::sdk::emit_budget::charge_emission("pubsub::shared_topic")?;
|
||||||
|
let json = message_to_json(&message)?;
|
||||||
|
// §9.4 before-op interceptor also guards shared-topic publishes. The
|
||||||
|
// collection is the namespace; the subtopic is the key.
|
||||||
|
let write_val = super::interceptor::run_before(
|
||||||
|
&h.ictx,
|
||||||
|
&h.cx,
|
||||||
|
"pubsub",
|
||||||
|
"publish",
|
||||||
|
&h.namespace,
|
||||||
|
subtopic,
|
||||||
|
Some(&json),
|
||||||
|
)?
|
||||||
|
.unwrap_or(json);
|
||||||
|
let written = write_val.clone();
|
||||||
|
let service = h.service.clone();
|
||||||
|
let cx = h.cx.clone();
|
||||||
|
let namespace = h.namespace.clone();
|
||||||
|
let subtopic_owned = subtopic.to_string();
|
||||||
|
block_on("pubsub", async move {
|
||||||
|
service
|
||||||
|
.publish(&cx, &namespace, &subtopic_owned, write_val)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
// The fan-out count isn't surfaced to scripts (fire-and-forget, like the
|
||||||
|
// per-app publish).
|
||||||
|
.map(|_n: u32| ())?;
|
||||||
|
super::interceptor::run_after(
|
||||||
|
&h.ictx,
|
||||||
|
&h.cx,
|
||||||
|
"pubsub",
|
||||||
|
"publish",
|
||||||
|
&h.namespace,
|
||||||
|
subtopic,
|
||||||
|
Some(&written),
|
||||||
|
Json::Null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_shared_publish(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"publish",
|
||||||
|
|h: &mut GroupTopicHandle, subtopic: &str, message: Dynamic| {
|
||||||
|
shared_publish(h, subtopic, message)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// `.publish(msg)` with no subtopic → publish to the namespace directly.
|
||||||
|
engine.register_fn("publish", |h: &mut GroupTopicHandle, message: Dynamic| {
|
||||||
|
shared_publish(h, "", message)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interpret the optional `ttl` argument: `()` → use the default,
|
/// Interpret the optional `ttl` argument: `()` → use the default,
|
||||||
@@ -125,38 +249,81 @@ fn mint_token(
|
|||||||
|
|
||||||
/// Convert a Rhai `Dynamic` message into JSON, base64-encoding any
|
/// Convert a Rhai `Dynamic` message into JSON, base64-encoding any
|
||||||
/// `Blob` (at any nesting depth). Mirrors `bridge::dynamic_to_json` but
|
/// `Blob` (at any nesting depth). Mirrors `bridge::dynamic_to_json` but
|
||||||
/// adds the blob arm the pub/sub wire contract requires.
|
/// adds the blob arm the pub/sub wire contract requires. Bounded by
|
||||||
fn message_to_json(value: &Dynamic) -> Json {
|
/// [`MAX_JSON_MATERIALIZE_BYTES`] so a cheaply-aliased huge message can't OOM
|
||||||
|
/// the node on materialization (the per-message wire cap is enforced later, on
|
||||||
|
/// the already-materialized value).
|
||||||
|
fn message_to_json(value: &Dynamic) -> Result<Json, Box<EvalAltResult>> {
|
||||||
|
let mut remaining = MAX_JSON_MATERIALIZE_BYTES;
|
||||||
|
message_to_json_bounded(value, &mut remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn msg_charge(remaining: &mut usize, n: usize) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
match remaining.checked_sub(n) {
|
||||||
|
Some(left) => {
|
||||||
|
*remaining = left;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(EvalAltResult::ErrorRuntime(
|
||||||
|
format!("pubsub: message too large to serialize (exceeds the {MAX_JSON_MATERIALIZE_BYTES}-byte limit)").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_to_json_bounded(
|
||||||
|
value: &Dynamic,
|
||||||
|
remaining: &mut usize,
|
||||||
|
) -> Result<Json, Box<EvalAltResult>> {
|
||||||
// Blob must be checked before the generic array path (a Blob is a
|
// Blob must be checked before the generic array path (a Blob is a
|
||||||
// `Vec<u8>`, distinct from a Rhai `Array`).
|
// `Vec<u8>`, distinct from a Rhai `Array`).
|
||||||
if value.is_blob() {
|
if value.is_blob() {
|
||||||
let blob = value.clone().into_blob().unwrap_or_default();
|
let blob = value.clone().into_blob().unwrap_or_default();
|
||||||
return Json::String(STANDARD.encode(&blob));
|
let encoded = STANDARD.encode(&blob);
|
||||||
|
msg_charge(remaining, encoded.len() + 2)?;
|
||||||
|
return Ok(Json::String(encoded));
|
||||||
}
|
}
|
||||||
if value.is_unit() {
|
if value.is_unit() {
|
||||||
return Json::Null;
|
msg_charge(remaining, 4)?;
|
||||||
|
return Ok(Json::Null);
|
||||||
}
|
}
|
||||||
if let Ok(b) = value.as_bool() {
|
if let Ok(b) = value.as_bool() {
|
||||||
return Json::Bool(b);
|
msg_charge(remaining, 5)?;
|
||||||
|
return Ok(Json::Bool(b));
|
||||||
}
|
}
|
||||||
if let Ok(i) = value.as_int() {
|
if let Ok(i) = value.as_int() {
|
||||||
return Json::Number(i.into());
|
msg_charge(remaining, 8)?;
|
||||||
|
return Ok(Json::Number(i.into()));
|
||||||
}
|
}
|
||||||
if let Ok(f) = value.as_float() {
|
if let Ok(f) = value.as_float() {
|
||||||
return serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number);
|
msg_charge(remaining, 8)?;
|
||||||
|
return Ok(serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number));
|
||||||
}
|
}
|
||||||
if value.is_string() {
|
if value.is_string() {
|
||||||
return Json::String(value.clone().into_string().unwrap_or_default());
|
let s = value.clone().into_string().unwrap_or_default();
|
||||||
|
msg_charge(remaining, s.len() + 2)?;
|
||||||
|
return Ok(Json::String(s));
|
||||||
}
|
}
|
||||||
if let Some(arr) = value.clone().try_cast::<Array>() {
|
if let Some(arr) = value.clone().try_cast::<Array>() {
|
||||||
return Json::Array(arr.iter().map(message_to_json).collect());
|
msg_charge(remaining, 2)?;
|
||||||
|
let mut out = Vec::with_capacity(arr.len().min(1024));
|
||||||
|
for v in &arr {
|
||||||
|
msg_charge(remaining, 1)?;
|
||||||
|
out.push(message_to_json_bounded(v, remaining)?);
|
||||||
|
}
|
||||||
|
return Ok(Json::Array(out));
|
||||||
}
|
}
|
||||||
if let Some(map) = value.clone().try_cast::<Map>() {
|
if let Some(map) = value.clone().try_cast::<Map>() {
|
||||||
|
msg_charge(remaining, 2)?;
|
||||||
let mut out = serde_json::Map::new();
|
let mut out = serde_json::Map::new();
|
||||||
for (k, v) in map {
|
for (k, v) in map {
|
||||||
out.insert(k.to_string(), message_to_json(&v));
|
msg_charge(remaining, k.len() + 4)?;
|
||||||
|
out.insert(k.to_string(), message_to_json_bounded(&v, remaining)?);
|
||||||
}
|
}
|
||||||
return Json::Object(out);
|
return Ok(Json::Object(out));
|
||||||
}
|
}
|
||||||
Json::String(value.to_string())
|
let s = value.to_string();
|
||||||
|
msg_charge(remaining, s.len() + 2)?;
|
||||||
|
Ok(Json::String(s))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,22 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use base64::engine::general_purpose::STANDARD;
|
use base64::engine::general_purpose::STANDARD;
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use picloud_shared::{EnqueueOpts, QueueError, QueueService, SdkCallCx, Services};
|
use picloud_shared::{
|
||||||
|
EnqueueOpts, GroupQueueService, QueueError, QueueService, SdkCallCx, Services,
|
||||||
|
};
|
||||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
use tokio::runtime::Handle as TokioHandle;
|
use tokio::runtime::Handle as TokioHandle;
|
||||||
|
|
||||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
use super::bridge::MAX_JSON_MATERIALIZE_BYTES;
|
||||||
|
use super::interceptor::InterceptorCtx;
|
||||||
|
|
||||||
|
pub(super) fn register(
|
||||||
|
engine: &mut RhaiEngine,
|
||||||
|
services: &Services,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
ictx: InterceptorCtx,
|
||||||
|
) {
|
||||||
let svc = services.queue.clone();
|
let svc = services.queue.clone();
|
||||||
let mut module = Module::new();
|
let mut module = Module::new();
|
||||||
|
|
||||||
@@ -33,11 +43,11 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
{
|
{
|
||||||
let svc = svc.clone();
|
let svc = svc.clone();
|
||||||
let cx = cx.clone();
|
let cx = cx.clone();
|
||||||
|
let ictx = ictx.clone();
|
||||||
module.set_native_fn(
|
module.set_native_fn(
|
||||||
"enqueue",
|
"enqueue",
|
||||||
move |name: &str, message: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
move |name: &str, message: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||||
let json = message_to_json(&message)?;
|
app_enqueue(&ictx, &svc, &cx, name, &message, EnqueueOpts::default())
|
||||||
enqueue_blocking(&svc, &cx, name, json, EnqueueOpts::default())
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -47,12 +57,12 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
{
|
{
|
||||||
let svc = svc.clone();
|
let svc = svc.clone();
|
||||||
let cx = cx.clone();
|
let cx = cx.clone();
|
||||||
|
let ictx = ictx.clone();
|
||||||
module.set_native_fn(
|
module.set_native_fn(
|
||||||
"enqueue",
|
"enqueue",
|
||||||
move |name: &str, message: Dynamic, opts: Map| -> Result<(), Box<EvalAltResult>> {
|
move |name: &str, message: Dynamic, opts: Map| -> Result<(), Box<EvalAltResult>> {
|
||||||
let json = message_to_json(&message)?;
|
|
||||||
let opts = parse_opts(&opts)?;
|
let opts = parse_opts(&opts)?;
|
||||||
enqueue_blocking(&svc, &cx, name, json, opts)
|
app_enqueue(&ictx, &svc, &cx, name, &message, opts)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -87,18 +97,176 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §11.6 D3: `queue::shared_collection("name")` → a handle over the group
|
||||||
|
// shared queue (any subtree app enqueues into one group-owned store;
|
||||||
|
// competing per-descendant consumers drain it). The owning group resolves
|
||||||
|
// from `cx.app_id` inside the service — never a script arg.
|
||||||
|
{
|
||||||
|
let group_svc = services.group_queue.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let ictx = ictx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"shared_collection",
|
||||||
|
move |name: &str| -> Result<GroupQueueHandle, Box<EvalAltResult>> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("queue::shared_collection name must not be empty".into());
|
||||||
|
}
|
||||||
|
Ok(GroupQueueHandle {
|
||||||
|
collection: name.to_string(),
|
||||||
|
service: group_svc.clone(),
|
||||||
|
cx: cx.clone(),
|
||||||
|
ictx: ictx.clone(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
engine.register_static_module("queue", module.into());
|
engine.register_static_module("queue", module.into());
|
||||||
|
engine.register_type_with_name::<GroupQueueHandle>("GroupQueueHandle");
|
||||||
|
register_shared_queue_methods(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §11.6 D3 handle returned by `queue::shared_collection("name")`.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GroupQueueHandle {
|
||||||
|
collection: String,
|
||||||
|
service: Arc<dyn GroupQueueService>,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
// §9.4: carried so shared `enqueue` runs the before/after hook too (M10).
|
||||||
|
ictx: InterceptorCtx,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-app enqueue with the §9.4 interceptor around it: charge the emission
|
||||||
|
/// budget, run the before-hook (allow/deny + M4 data-transform), enqueue the
|
||||||
|
/// (possibly rewritten) message, then run the after-hook with the new message
|
||||||
|
/// id as the result. `key` is `""` (a queue has no key). `collection` = the
|
||||||
|
/// queue name.
|
||||||
|
fn app_enqueue(
|
||||||
|
ictx: &InterceptorCtx,
|
||||||
|
svc: &Arc<dyn QueueService>,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
name: &str,
|
||||||
|
message: &Dynamic,
|
||||||
|
opts: EnqueueOpts,
|
||||||
|
) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
crate::sdk::emit_budget::charge_emission("queue::enqueue")?;
|
||||||
|
let json = message_to_json(message)?;
|
||||||
|
let write_val =
|
||||||
|
super::interceptor::run_before(ictx, cx, "queue", "enqueue", name, "", Some(&json))?
|
||||||
|
.unwrap_or(json);
|
||||||
|
let written = write_val.clone();
|
||||||
|
let id = enqueue_blocking(svc, cx, name, write_val, opts)?;
|
||||||
|
super::interceptor::run_after(
|
||||||
|
ictx,
|
||||||
|
cx,
|
||||||
|
"queue",
|
||||||
|
"enqueue",
|
||||||
|
name,
|
||||||
|
"",
|
||||||
|
Some(&written),
|
||||||
|
Json::String(id.0.to_string()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_enqueue(
|
||||||
|
h: &mut GroupQueueHandle,
|
||||||
|
message: Dynamic,
|
||||||
|
opts: EnqueueOpts,
|
||||||
|
) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
crate::sdk::emit_budget::charge_emission("queue::shared_collection")?;
|
||||||
|
let json = message_to_json(&message)?;
|
||||||
|
// §9.4 before-op interceptor also guards shared-queue enqueues.
|
||||||
|
let write_val = super::interceptor::run_before(
|
||||||
|
&h.ictx,
|
||||||
|
&h.cx,
|
||||||
|
"queue",
|
||||||
|
"enqueue",
|
||||||
|
&h.collection,
|
||||||
|
"",
|
||||||
|
Some(&json),
|
||||||
|
)?
|
||||||
|
.unwrap_or(json);
|
||||||
|
let written = write_val.clone();
|
||||||
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("queue: no tokio runtime available: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
let service = h.service.clone();
|
||||||
|
let cx = h.cx.clone();
|
||||||
|
let collection = h.collection.clone();
|
||||||
|
let id = handle
|
||||||
|
.block_on(async move { service.enqueue(&cx, &collection, write_val, opts).await })
|
||||||
|
.map_err(|err| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(format!("queue: {err}").into(), rhai::Position::NONE).into()
|
||||||
|
})?;
|
||||||
|
super::interceptor::run_after(
|
||||||
|
&h.ictx,
|
||||||
|
&h.cx,
|
||||||
|
"queue",
|
||||||
|
"enqueue",
|
||||||
|
&h.collection,
|
||||||
|
"",
|
||||||
|
Some(&written),
|
||||||
|
Json::String(id.0.to_string()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_depth<F, Fut>(h: &mut GroupQueueHandle, f: F) -> Result<i64, Box<EvalAltResult>>
|
||||||
|
where
|
||||||
|
F: FnOnce(Arc<dyn GroupQueueService>, Arc<SdkCallCx>, String) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<u64, picloud_shared::GroupQueueError>>,
|
||||||
|
{
|
||||||
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("queue: no tokio runtime available: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
let fut = f(h.service.clone(), h.cx.clone(), h.collection.clone());
|
||||||
|
let n = handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(format!("queue: {err}").into(), rhai::Position::NONE).into()
|
||||||
|
})?;
|
||||||
|
Ok(i64::try_from(n).unwrap_or(i64::MAX))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_shared_queue_methods(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn("enqueue", |h: &mut GroupQueueHandle, message: Dynamic| {
|
||||||
|
group_enqueue(h, message, EnqueueOpts::default())
|
||||||
|
});
|
||||||
|
engine.register_fn(
|
||||||
|
"enqueue",
|
||||||
|
|h: &mut GroupQueueHandle, message: Dynamic, opts: Map| {
|
||||||
|
let opts = parse_opts(&opts)?;
|
||||||
|
group_enqueue(h, message, opts)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn("depth", |h: &mut GroupQueueHandle| {
|
||||||
|
group_depth(
|
||||||
|
h,
|
||||||
|
|svc, cx, coll| async move { svc.depth(&cx, &coll).await },
|
||||||
|
)
|
||||||
|
});
|
||||||
|
engine.register_fn("depth_pending", |h: &mut GroupQueueHandle| {
|
||||||
|
group_depth(h, |svc, cx, coll| async move {
|
||||||
|
svc.depth_pending(&cx, &coll).await
|
||||||
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the async enqueue inside the synchronous Rhai context. Mirrors
|
/// Run the async enqueue inside the synchronous Rhai context. Mirrors
|
||||||
/// `pubsub::block_on` but for the queue's `Result<_, QueueError>`.
|
/// `pubsub::block_on` but for the queue's `Result<_, QueueError>`. Returns the
|
||||||
|
/// new message id (surfaced to the §9.4 after-hook, not to the script).
|
||||||
fn enqueue_blocking(
|
fn enqueue_blocking(
|
||||||
svc: &Arc<dyn QueueService>,
|
svc: &Arc<dyn QueueService>,
|
||||||
cx: &Arc<SdkCallCx>,
|
cx: &Arc<SdkCallCx>,
|
||||||
name: &str,
|
name: &str,
|
||||||
payload: Json,
|
payload: Json,
|
||||||
opts: EnqueueOpts,
|
opts: EnqueueOpts,
|
||||||
) -> Result<(), Box<EvalAltResult>> {
|
) -> Result<picloud_shared::QueueMessageId, Box<EvalAltResult>> {
|
||||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||||
EvalAltResult::ErrorRuntime(
|
EvalAltResult::ErrorRuntime(
|
||||||
format!("queue: no tokio runtime available: {e}").into(),
|
format!("queue: no tokio runtime available: {e}").into(),
|
||||||
@@ -111,7 +279,6 @@ fn enqueue_blocking(
|
|||||||
let name = name.to_string();
|
let name = name.to_string();
|
||||||
handle
|
handle
|
||||||
.block_on(async move { svc.enqueue(&cx, &name, payload, opts).await })
|
.block_on(async move { svc.enqueue(&cx, &name, payload, opts).await })
|
||||||
.map(|_id| ())
|
|
||||||
.map_err(|err| -> Box<EvalAltResult> {
|
.map_err(|err| -> Box<EvalAltResult> {
|
||||||
EvalAltResult::ErrorRuntime(format!("queue: {err}").into(), rhai::Position::NONE).into()
|
EvalAltResult::ErrorRuntime(format!("queue: {err}").into(), rhai::Position::NONE).into()
|
||||||
})
|
})
|
||||||
@@ -161,8 +328,33 @@ fn parse_opts(opts: &Map) -> Result<EnqueueOpts, Box<EvalAltResult>> {
|
|||||||
/// Convert a Rhai `Dynamic` into JSON. Mirrors `pubsub::message_to_json`
|
/// Convert a Rhai `Dynamic` into JSON. Mirrors `pubsub::message_to_json`
|
||||||
/// — blobs become base64, FnPtr / closures are rejected so a script
|
/// — blobs become base64, FnPtr / closures are rejected so a script
|
||||||
/// can't accidentally enqueue something that won't survive a trip
|
/// can't accidentally enqueue something that won't survive a trip
|
||||||
/// through Postgres + back through the bridge.
|
/// through Postgres + back through the bridge. Bounded by
|
||||||
|
/// [`MAX_JSON_MATERIALIZE_BYTES`] so a cheaply-aliased huge message can't OOM
|
||||||
|
/// the node on materialization (the per-message payload cap is enforced later,
|
||||||
|
/// on the already-materialized value).
|
||||||
fn message_to_json(value: &Dynamic) -> Result<Json, Box<EvalAltResult>> {
|
fn message_to_json(value: &Dynamic) -> Result<Json, Box<EvalAltResult>> {
|
||||||
|
let mut remaining = MAX_JSON_MATERIALIZE_BYTES;
|
||||||
|
message_to_json_bounded(value, &mut remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn msg_charge(remaining: &mut usize, n: usize) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
match remaining.checked_sub(n) {
|
||||||
|
Some(left) => {
|
||||||
|
*remaining = left;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(EvalAltResult::ErrorRuntime(
|
||||||
|
format!("queue::enqueue: message too large to serialize (exceeds the {MAX_JSON_MATERIALIZE_BYTES}-byte limit)").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_to_json_bounded(
|
||||||
|
value: &Dynamic,
|
||||||
|
remaining: &mut usize,
|
||||||
|
) -> Result<Json, Box<EvalAltResult>> {
|
||||||
if value.is::<rhai::FnPtr>() {
|
if value.is::<rhai::FnPtr>() {
|
||||||
return Err(EvalAltResult::ErrorRuntime(
|
return Err(EvalAltResult::ErrorRuntime(
|
||||||
"queue::enqueue: messages must not contain FnPtr / closures".into(),
|
"queue::enqueue: messages must not contain FnPtr / closures".into(),
|
||||||
@@ -172,40 +364,52 @@ fn message_to_json(value: &Dynamic) -> Result<Json, Box<EvalAltResult>> {
|
|||||||
}
|
}
|
||||||
if value.is_blob() {
|
if value.is_blob() {
|
||||||
let blob = value.clone().into_blob().unwrap_or_default();
|
let blob = value.clone().into_blob().unwrap_or_default();
|
||||||
return Ok(Json::String(STANDARD.encode(&blob)));
|
let encoded = STANDARD.encode(&blob);
|
||||||
|
msg_charge(remaining, encoded.len() + 2)?;
|
||||||
|
return Ok(Json::String(encoded));
|
||||||
}
|
}
|
||||||
if value.is_unit() {
|
if value.is_unit() {
|
||||||
|
msg_charge(remaining, 4)?;
|
||||||
return Ok(Json::Null);
|
return Ok(Json::Null);
|
||||||
}
|
}
|
||||||
if let Ok(b) = value.as_bool() {
|
if let Ok(b) = value.as_bool() {
|
||||||
|
msg_charge(remaining, 5)?;
|
||||||
return Ok(Json::Bool(b));
|
return Ok(Json::Bool(b));
|
||||||
}
|
}
|
||||||
if let Ok(i) = value.as_int() {
|
if let Ok(i) = value.as_int() {
|
||||||
|
msg_charge(remaining, 8)?;
|
||||||
return Ok(Json::Number(i.into()));
|
return Ok(Json::Number(i.into()));
|
||||||
}
|
}
|
||||||
if let Ok(f) = value.as_float() {
|
if let Ok(f) = value.as_float() {
|
||||||
|
msg_charge(remaining, 8)?;
|
||||||
return Ok(serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number));
|
return Ok(serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number));
|
||||||
}
|
}
|
||||||
if value.is_string() {
|
if value.is_string() {
|
||||||
return Ok(Json::String(
|
let s = value.clone().into_string().unwrap_or_default();
|
||||||
value.clone().into_string().unwrap_or_default(),
|
msg_charge(remaining, s.len() + 2)?;
|
||||||
));
|
return Ok(Json::String(s));
|
||||||
}
|
}
|
||||||
if let Some(arr) = value.clone().try_cast::<Array>() {
|
if let Some(arr) = value.clone().try_cast::<Array>() {
|
||||||
let mut out = Vec::with_capacity(arr.len());
|
msg_charge(remaining, 2)?;
|
||||||
|
let mut out = Vec::with_capacity(arr.len().min(1024));
|
||||||
for v in &arr {
|
for v in &arr {
|
||||||
out.push(message_to_json(v)?);
|
msg_charge(remaining, 1)?;
|
||||||
|
out.push(message_to_json_bounded(v, remaining)?);
|
||||||
}
|
}
|
||||||
return Ok(Json::Array(out));
|
return Ok(Json::Array(out));
|
||||||
}
|
}
|
||||||
if let Some(map) = value.clone().try_cast::<Map>() {
|
if let Some(map) = value.clone().try_cast::<Map>() {
|
||||||
|
msg_charge(remaining, 2)?;
|
||||||
let mut out = serde_json::Map::new();
|
let mut out = serde_json::Map::new();
|
||||||
for (k, v) in map {
|
for (k, v) in map {
|
||||||
out.insert(k.to_string(), message_to_json(&v)?);
|
msg_charge(remaining, k.len() + 4)?;
|
||||||
|
out.insert(k.to_string(), message_to_json_bounded(&v, remaining)?);
|
||||||
}
|
}
|
||||||
return Ok(Json::Object(out));
|
return Ok(Json::Object(out));
|
||||||
}
|
}
|
||||||
Ok(Json::String(value.to_string()))
|
let s = value.to_string();
|
||||||
|
msg_charge(remaining, s.len() + 2)?;
|
||||||
|
Ok(Json::String(s))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ use std::sync::Arc;
|
|||||||
use picloud_shared::{SdkCallCx, SecretsListPage, Services};
|
use picloud_shared::{SdkCallCx, SecretsListPage, Services};
|
||||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
|
|
||||||
use super::bridge::{block_on, dynamic_to_json, json_to_dynamic};
|
use super::bridge::{
|
||||||
|
block_on, dynamic_to_json_capped, json_to_dynamic, runtime_err, MAX_JSON_MATERIALIZE_BYTES,
|
||||||
|
};
|
||||||
|
|
||||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
let svc = services.secrets.clone();
|
let svc = services.secrets.clone();
|
||||||
@@ -34,7 +36,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
module.set_native_fn(
|
module.set_native_fn(
|
||||||
"set",
|
"set",
|
||||||
move |name: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
move |name: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||||
let json = dynamic_to_json(&value);
|
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
let svc = svc.clone();
|
let svc = svc.clone();
|
||||||
let cx = cx.clone();
|
let cx = cx.clone();
|
||||||
block_on("secrets", async move { svc.set(&cx, name, json).await })
|
block_on("secrets", async move { svc.set(&cx, name, json).await })
|
||||||
@@ -124,10 +126,3 @@ fn list_page_to_map(page: SecretsListPage) -> Map {
|
|||||||
);
|
);
|
||||||
m
|
m
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the boxed error directly because every caller needs a
|
|
||||||
// `Box<EvalAltResult>` (Rhai's error type), matching the other bridges.
|
|
||||||
#[allow(clippy::unnecessary_box_returns)]
|
|
||||||
fn runtime_err(msg: &str) -> Box<EvalAltResult> {
|
|
||||||
EvalAltResult::ErrorRuntime(msg.into(), rhai::Position::NONE).into()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Module};
|
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Module};
|
||||||
|
|
||||||
use crate::sdk::bridge::{dynamic_to_json, json_to_dynamic};
|
use crate::sdk::bridge::{dynamic_to_json_capped, json_to_dynamic, MAX_JSON_MATERIALIZE_BYTES};
|
||||||
|
|
||||||
pub fn register(engine: &mut RhaiEngine) {
|
pub fn register(engine: &mut RhaiEngine) {
|
||||||
let mut module = Module::new();
|
let mut module = Module::new();
|
||||||
@@ -26,7 +26,7 @@ fn register_stringify(module: &mut Module) {
|
|||||||
module.set_native_fn(
|
module.set_native_fn(
|
||||||
"stringify",
|
"stringify",
|
||||||
|v: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
|v: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
||||||
serde_json::to_string(&dynamic_to_json(&v))
|
serde_json::to_string(&dynamic_to_json_capped(&v, MAX_JSON_MATERIALIZE_BYTES)?)
|
||||||
.map_err(|e| format!("json::stringify: {e}").into())
|
.map_err(|e| format!("json::stringify: {e}").into())
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -36,7 +36,7 @@ fn register_stringify_pretty(module: &mut Module) {
|
|||||||
module.set_native_fn(
|
module.set_native_fn(
|
||||||
"stringify_pretty",
|
"stringify_pretty",
|
||||||
|v: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
|v: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
||||||
serde_json::to_string_pretty(&dynamic_to_json(&v))
|
serde_json::to_string_pretty(&dynamic_to_json_capped(&v, MAX_JSON_MATERIALIZE_BYTES)?)
|
||||||
.map_err(|e| format!("json::stringify_pretty: {e}").into())
|
.map_err(|e| format!("json::stringify_pretty: {e}").into())
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use super::bridge::block_on;
|
use super::bridge::{block_on, runtime_err};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppUser, AppUserId, CreateUserInput, EmailTemplateOpts, GeneratedAccept, GeneratedSession,
|
AppUser, AppUserId, CreateUserInput, EmailTemplateOpts, GeneratedAccept, GeneratedSession,
|
||||||
InviteOpts, SdkCallCx, Services, UpdateUserInput, UsersListOpts, UsersListPage, UsersService,
|
InviteOpts, SdkCallCx, Services, UpdateUserInput, UsersListOpts, UsersListPage, UsersService,
|
||||||
@@ -615,8 +615,3 @@ fn optional_string(opts: &Map, key: &str) -> Option<String> {
|
|||||||
Some(d) => Some(d.to_string()),
|
Some(d) => Some(d.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::unnecessary_box_returns)]
|
|
||||||
fn runtime_err(msg: &str) -> Box<EvalAltResult> {
|
|
||||||
EvalAltResult::ErrorRuntime(msg.into(), rhai::Position::NONE).into()
|
|
||||||
}
|
|
||||||
|
|||||||
57
crates/executor-core/src/sdk/vars.rs
Normal file
57
crates/executor-core/src/sdk/vars.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
//! `vars::` Rhai bridge — read-only access to the app's resolved,
|
||||||
|
//! group-inherited config (Phase 3).
|
||||||
|
//!
|
||||||
|
//! ```rhai
|
||||||
|
//! let region = vars::get("region"); // value or ()
|
||||||
|
//! let all = vars::all(); // #{ key: value, ... }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Values are inherited down the group tree and env-filtered (§3); the
|
||||||
|
//! resolution happens server-side in manager-core. Writes go through the
|
||||||
|
//! admin API, not the SDK. `app_id` is derived from `cx.app_id` in the
|
||||||
|
//! service — never a script argument — preserving cross-app isolation.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use picloud_shared::{SdkCallCx, Services};
|
||||||
|
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
|
|
||||||
|
use super::bridge::{block_on, json_to_dynamic};
|
||||||
|
|
||||||
|
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
|
let svc = services.vars.clone();
|
||||||
|
let mut module = Module::new();
|
||||||
|
|
||||||
|
// vars::get(key) — resolved value, or () if no level defines it.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"get",
|
||||||
|
move |key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let opt = block_on("vars", async move { svc.get(&cx, key).await })?;
|
||||||
|
Ok(opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// vars::all() — the fully-resolved config map.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn("all", move || -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let resolved = block_on("vars", async move { svc.all(&cx).await })?;
|
||||||
|
let mut m = Map::new();
|
||||||
|
for (k, v) in resolved {
|
||||||
|
m.insert(k.into(), json_to_dynamic(v));
|
||||||
|
}
|
||||||
|
Ok(m)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.register_static_module("vars", module.into());
|
||||||
|
}
|
||||||
156
crates/executor-core/src/sdk/workflow.rs
Normal file
156
crates/executor-core/src/sdk/workflow.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
//! `workflow::` Rhai bridge — start a durable workflow run + read its status.
|
||||||
|
//!
|
||||||
|
//! ```rhai
|
||||||
|
//! let run_id = workflow::start("nightly_report", #{ date: "2026-07-12" });
|
||||||
|
//! let run_id = workflow::start("cleanup"); // no input
|
||||||
|
//! let st = workflow::run_status(run_id); // #{ status, output, error, steps } | ()
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Returns the run id as a string. The owning workflow resolves from
|
||||||
|
//! `cx.app_id` inside the service — never a script arg (the isolation
|
||||||
|
//! boundary). The durable orchestrator advances the run asynchronously; the
|
||||||
|
//! script does not block on completion. `run_status` (F-038) lets the starter
|
||||||
|
//! poll progress without the platform-admin API — `()` when no such run
|
||||||
|
//! belongs to this app.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use picloud_shared::{SdkCallCx, Services, WorkflowRunId, WorkflowRunView, WorkflowService};
|
||||||
|
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
|
use tokio::runtime::Handle as TokioHandle;
|
||||||
|
|
||||||
|
use crate::sdk::bridge::{dynamic_to_json_capped, json_to_dynamic, MAX_JSON_MATERIALIZE_BYTES};
|
||||||
|
|
||||||
|
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
|
let svc = services.workflow.clone();
|
||||||
|
let mut module = Module::new();
|
||||||
|
|
||||||
|
// `workflow::start(name, input)`
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"start",
|
||||||
|
move |name: &str, input: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
start_blocking(&svc, &cx, name, &input)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `workflow::start(name)` — no input (passes JSON null).
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"start",
|
||||||
|
move |name: &str| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
start_blocking(&svc, &cx, name, &Dynamic::UNIT)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `workflow::run_status(run_id)` — read-only poll (F-038). `()` if absent.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"run_status",
|
||||||
|
move |run_id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
run_status_blocking(&svc, &cx, run_id)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.register_static_module("workflow", module.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_blocking(
|
||||||
|
svc: &Arc<dyn WorkflowService>,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
name: &str,
|
||||||
|
input: &Dynamic,
|
||||||
|
) -> Result<String, Box<EvalAltResult>> {
|
||||||
|
let input_json = if input.is_unit() {
|
||||||
|
serde_json::Value::Null
|
||||||
|
} else {
|
||||||
|
dynamic_to_json_capped(input, MAX_JSON_MATERIALIZE_BYTES)?
|
||||||
|
};
|
||||||
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("workflow::start: no tokio runtime available: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let name = name.to_string();
|
||||||
|
let run_id = handle
|
||||||
|
.block_on(async move { svc.start(&cx, &name, input_json).await })
|
||||||
|
.map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("workflow::start: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
Ok(run_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_status_blocking(
|
||||||
|
svc: &Arc<dyn WorkflowService>,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
run_id: &str,
|
||||||
|
) -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let run_id: WorkflowRunId = run_id
|
||||||
|
.parse::<uuid::Uuid>()
|
||||||
|
.map(WorkflowRunId::from)
|
||||||
|
.map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("workflow::run_status: invalid run id {run_id:?}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("workflow::run_status: no tokio runtime available: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let view = handle
|
||||||
|
.block_on(async move { svc.run_status(&cx, run_id).await })
|
||||||
|
.map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("workflow::run_status: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
// Absent run → `()` so a script can test `if status == ()`.
|
||||||
|
Ok(view.map_or(Dynamic::UNIT, |v| view_to_dynamic(&v)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a run view into the Rhai shape
|
||||||
|
/// `#{ status, output, error, steps: #{ name: status } }`.
|
||||||
|
fn view_to_dynamic(v: &WorkflowRunView) -> Dynamic {
|
||||||
|
let mut m = Map::new();
|
||||||
|
m.insert("status".into(), v.status.as_str().into());
|
||||||
|
m.insert(
|
||||||
|
"output".into(),
|
||||||
|
v.output.clone().map_or(Dynamic::UNIT, json_to_dynamic),
|
||||||
|
);
|
||||||
|
m.insert(
|
||||||
|
"error".into(),
|
||||||
|
v.error.clone().map_or(Dynamic::UNIT, Into::into),
|
||||||
|
);
|
||||||
|
let mut steps = Map::new();
|
||||||
|
for s in &v.steps {
|
||||||
|
steps.insert(s.name.clone().into(), s.status.as_str().into());
|
||||||
|
}
|
||||||
|
m.insert("steps".into(), steps.into());
|
||||||
|
Dynamic::from_map(m)
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ExecutionId, Principal, RequestId, ScriptId, ScriptSandbox, TriggerEvent,
|
AppId, ExecutionId, ExecutionLog, ExecutionSource, ExecutionStatus, Principal, RequestId,
|
||||||
|
ScriptId, ScriptOwner, ScriptSandbox, TriggerEvent,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -67,6 +69,16 @@ pub struct ExecRequest {
|
|||||||
/// Internal-only; not surfaced via `ctx` (which the script sees).
|
/// Internal-only; not surfaced via `ctx` (which the script sees).
|
||||||
pub app_id: AppId,
|
pub app_id: AppId,
|
||||||
|
|
||||||
|
/// The **defining node** of the top-level script being executed —
|
||||||
|
/// the resolved `Script`'s owner (Phase 4b). This is the lexical
|
||||||
|
/// origin its `import` statements resolve against (§5.5): an inherited
|
||||||
|
/// group endpoint's imports seal to the **group**, not the inheriting
|
||||||
|
/// app, so a leaf can't shadow them. Distinct from `app_id`, which is
|
||||||
|
/// always the execution boundary (where SDK calls scope). `None` (old
|
||||||
|
/// payloads / cluster wire) falls back to `App(app_id)` in the engine.
|
||||||
|
#[serde(default)]
|
||||||
|
pub script_owner: Option<ScriptOwner>,
|
||||||
|
|
||||||
/// Caller identity, when authenticated. `None` for unauthenticated
|
/// Caller identity, when authenticated. `None` for unauthenticated
|
||||||
/// data-plane HTTP requests (the common case for public scripts);
|
/// data-plane HTTP requests (the common case for public scripts);
|
||||||
/// `Some` when a bearer token or session cookie was resolved.
|
/// `Some` when a bearer token or session cookie was resolved.
|
||||||
@@ -111,6 +123,13 @@ pub struct ExecResponse {
|
|||||||
pub status_code: u16,
|
pub status_code: u16,
|
||||||
pub headers: BTreeMap<String, String>,
|
pub headers: BTreeMap<String, String>,
|
||||||
pub body: serde_json::Value,
|
pub body: serde_json::Value,
|
||||||
|
/// F-026 binary response. When a script returns an envelope with a
|
||||||
|
/// `body_base64` field, the HTTP layer decodes it and returns the raw
|
||||||
|
/// bytes with the script-set `Content-Type` (instead of JSON-encoding
|
||||||
|
/// `body`). `None` ⇒ the normal JSON `body` path. `#[serde(default)]`
|
||||||
|
/// keeps the cluster-mode wire format backward-compatible.
|
||||||
|
#[serde(default)]
|
||||||
|
pub body_base64: Option<String>,
|
||||||
pub logs: Vec<LogEntry>,
|
pub logs: Vec<LogEntry>,
|
||||||
pub stats: ExecStats,
|
pub stats: ExecStats,
|
||||||
}
|
}
|
||||||
@@ -167,3 +186,77 @@ pub enum ExecError {
|
|||||||
#[error("execution declined: server at capacity (retry after {retry_after_secs}s)")]
|
#[error("execution declined: server at capacity (retry after {retry_after_secs}s)")]
|
||||||
Overloaded { retry_after_secs: u32 },
|
Overloaded { retry_after_secs: u32 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build an `ExecutionLog` row from one invocation's outcome.
|
||||||
|
///
|
||||||
|
/// Shared by every execution path so the log shape stays identical
|
||||||
|
/// regardless of who ran the script: the orchestrator's sync/direct HTTP
|
||||||
|
/// handlers pass `ExecutionSource::Http`, while the manager's trigger
|
||||||
|
/// dispatcher passes the trigger's kind (`Kv`, `Cron`, `Invoke`, …). That
|
||||||
|
/// `source` is what lets `pic logs` surface background runs that were
|
||||||
|
/// previously invisible. `Overloaded` is never logged — admission is
|
||||||
|
/// refused before a row exists — but it maps to `Error` defensively.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
#[must_use]
|
||||||
|
pub fn build_execution_log(
|
||||||
|
app_id: AppId,
|
||||||
|
script_id: ScriptId,
|
||||||
|
request_id: RequestId,
|
||||||
|
request_path: String,
|
||||||
|
request_headers: BTreeMap<String, String>,
|
||||||
|
request_body: serde_json::Value,
|
||||||
|
source: ExecutionSource,
|
||||||
|
outcome: &Result<ExecResponse, ExecError>,
|
||||||
|
started: DateTime<Utc>,
|
||||||
|
finished: DateTime<Utc>,
|
||||||
|
) -> ExecutionLog {
|
||||||
|
let duration_ms = u64::try_from(
|
||||||
|
finished
|
||||||
|
.signed_duration_since(started)
|
||||||
|
.num_milliseconds()
|
||||||
|
.max(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let (status, response_code, response_body, script_logs) = match outcome {
|
||||||
|
Ok(resp) => {
|
||||||
|
let logs = serde_json::to_value(&resp.logs).unwrap_or(serde_json::Value::Array(vec![]));
|
||||||
|
(
|
||||||
|
ExecutionStatus::Success,
|
||||||
|
Some(resp.status_code),
|
||||||
|
Some(resp.body.clone()),
|
||||||
|
logs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let status = match e {
|
||||||
|
ExecError::Timeout(_) => ExecutionStatus::Timeout,
|
||||||
|
ExecError::OperationBudgetExceeded => ExecutionStatus::BudgetExceeded,
|
||||||
|
_ => ExecutionStatus::Error,
|
||||||
|
};
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
None,
|
||||||
|
Some(serde_json::json!({ "error": e.to_string() })),
|
||||||
|
serde_json::Value::Array(vec![]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ExecutionLog {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
app_id,
|
||||||
|
script_id,
|
||||||
|
request_id,
|
||||||
|
request_path,
|
||||||
|
request_headers,
|
||||||
|
request_body,
|
||||||
|
response_code,
|
||||||
|
response_body,
|
||||||
|
script_logs,
|
||||||
|
duration_ms,
|
||||||
|
status,
|
||||||
|
source,
|
||||||
|
created_at: started,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ fn req(body: serde_json::Value) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id: AppId::new(),
|
app_id: AppId::new(),
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
@@ -92,6 +93,26 @@ fn returns_structured_response_when_status_code_present() {
|
|||||||
assert_eq!(resp.body, json!({ "ok": true, "msg": "created" }));
|
assert_eq!(resp.body, json!({ "ok": true, "msg": "created" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn body_base64_envelope_sets_binary_response() {
|
||||||
|
// F-026: a `body_base64` field carries raw bytes; `body` is ignored.
|
||||||
|
let src = r#"
|
||||||
|
#{ statusCode: 200,
|
||||||
|
headers: #{ "content-type": "image/png" },
|
||||||
|
body: #{ ignored: true },
|
||||||
|
body_base64: "aGVsbG8=" }
|
||||||
|
"#;
|
||||||
|
let resp = engine().execute(src, req(json!(null))).unwrap();
|
||||||
|
assert_eq!(resp.status_code, 200);
|
||||||
|
assert_eq!(resp.body_base64.as_deref(), Some("aGVsbG8="));
|
||||||
|
// `body` is nulled out when body_base64 is present.
|
||||||
|
assert_eq!(resp.body, json!(null));
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers.get("content-type").map(String::as_str),
|
||||||
|
Some("image/png")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ctx_exposes_request_data() {
|
fn ctx_exposes_request_data() {
|
||||||
let src = r"
|
let src = r"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
|||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
|
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
|
||||||
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
|
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
|
||||||
ScriptSandbox, SdkCallCx, Services,
|
ScriptOwner, ScriptSandbox, Services,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tracing_subscriber::fmt::MakeWriter;
|
use tracing_subscriber::fmt::MakeWriter;
|
||||||
@@ -27,9 +27,9 @@ struct FailingSource;
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ModuleSource for FailingSource {
|
impl ModuleSource for FailingSource {
|
||||||
async fn lookup(
|
async fn resolve(
|
||||||
&self,
|
&self,
|
||||||
_cx: &SdkCallCx,
|
_origin: ScriptOwner,
|
||||||
_name: &str,
|
_name: &str,
|
||||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||||
@@ -74,6 +74,7 @@ fn req(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
@@ -107,6 +108,7 @@ async fn original_backend_error_is_logged_at_error_level() {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
let engine = Engine::new(Limits::default(), services);
|
let engine = Engine::new(Limits::default(), services);
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
//! verify the same code path the `picloud` binary runs at request
|
//! verify the same code path the `picloud` binary runs at request
|
||||||
//! time.
|
//! time.
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@ use async_trait::async_trait;
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
|
AppId, ExecutionId, GroupId, ModuleScript, ModuleSource, ModuleSourceError,
|
||||||
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
|
NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService,
|
||||||
ScriptSandbox, SdkCallCx, Services,
|
RequestId, ScriptId, ScriptOwner, ScriptSandbox, Services,
|
||||||
};
|
};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
@@ -55,7 +55,8 @@ impl CountingModuleSource {
|
|||||||
(app_id, name.to_string()),
|
(app_id, name.to_string()),
|
||||||
ModuleScript {
|
ModuleScript {
|
||||||
script_id,
|
script_id,
|
||||||
app_id,
|
app_id: Some(app_id),
|
||||||
|
group_id: None,
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
source: source.to_string(),
|
source: source.to_string(),
|
||||||
updated_at,
|
updated_at,
|
||||||
@@ -71,20 +72,27 @@ impl CountingModuleSource {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ModuleSource for CountingModuleSource {
|
impl ModuleSource for CountingModuleSource {
|
||||||
async fn lookup(
|
async fn resolve(
|
||||||
&self,
|
&self,
|
||||||
cx: &SdkCallCx,
|
origin: ScriptOwner,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
self.lookups.fetch_add(1, Ordering::SeqCst);
|
self.lookups.fetch_add(1, Ordering::SeqCst);
|
||||||
if let Some(err) = self.fail_with.lock().await.as_ref() {
|
if let Some(err) = self.fail_with.lock().await.as_ref() {
|
||||||
return Err(ModuleSourceError::Backend(err.clone()));
|
return Err(ModuleSourceError::Backend(err.clone()));
|
||||||
}
|
}
|
||||||
|
// This fake is flat/app-scoped — the inheritance + lexical
|
||||||
|
// resolution semantics are covered by the CLI journey tests
|
||||||
|
// against real Postgres. A group origin has no entries here.
|
||||||
|
let app_id = match origin {
|
||||||
|
ScriptOwner::App(a) => a,
|
||||||
|
ScriptOwner::Group(_) => return Ok(None),
|
||||||
|
};
|
||||||
Ok(self
|
Ok(self
|
||||||
.table
|
.table
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.get(&(cx.app_id, name.to_string()))
|
.get(&(app_id, name.to_string()))
|
||||||
.cloned())
|
.cloned())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,6 +112,7 @@ fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +137,7 @@ fn req(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
@@ -599,3 +609,277 @@ fn validate_endpoint_skips_dynamic_imports_in_imports_list() {
|
|||||||
v.imports
|
v.imports
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase 4b — lexical (origin-aware) resolution.
|
||||||
|
//
|
||||||
|
// `LexicalModuleSource` keys modules by their *exact* defining node, with no
|
||||||
|
// chain walk. That's enough to prove the resolver passes the RIGHT origin:
|
||||||
|
// `default_origin` for the entry AST, and each module's own owner (decoded
|
||||||
|
// from Rhai's `_source`) for its nested imports. The chain-walk + group-tree
|
||||||
|
// semantics are covered end-to-end by the CLI journey tests against Postgres.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct LexicalModuleSource {
|
||||||
|
table: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LexicalModuleSource {
|
||||||
|
fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) {
|
||||||
|
let (app_id, group_id) = match owner {
|
||||||
|
ScriptOwner::App(a) => (Some(a), None),
|
||||||
|
ScriptOwner::Group(g) => (None, Some(g)),
|
||||||
|
};
|
||||||
|
self.table.lock().await.insert(
|
||||||
|
(owner, name.to_string()),
|
||||||
|
ModuleScript {
|
||||||
|
script_id: ScriptId::new(),
|
||||||
|
app_id,
|
||||||
|
group_id,
|
||||||
|
name: name.to_string(),
|
||||||
|
source: source.to_string(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ModuleSource for LexicalModuleSource {
|
||||||
|
async fn resolve(
|
||||||
|
&self,
|
||||||
|
origin: ScriptOwner,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
|
Ok(self
|
||||||
|
.table
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&(origin, name.to_string()))
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn req_with_owner(app_id: AppId, owner: ScriptOwner) -> ExecRequest {
|
||||||
|
let mut r = req(app_id);
|
||||||
|
r.script_owner = Some(owner);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group module's `import` resolves from the GROUP (its own defining
|
||||||
|
/// node), not the inheriting app — even when an app-owned module of the
|
||||||
|
/// same name exists. This is the §5.5 trust boundary: a leaf can't shadow
|
||||||
|
/// a module an inherited group script depends on.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn lexical_nested_import_seals_to_module_owner() {
|
||||||
|
let source = LexicalModuleSource::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
|
||||||
|
// Two "inner" modules: the group's (real) and the app's (a trap).
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
|
||||||
|
.await;
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
|
||||||
|
.await;
|
||||||
|
// The group's "outer" imports "inner" — must bind the group's inner.
|
||||||
|
source
|
||||||
|
.put(
|
||||||
|
ScriptOwner::Group(group),
|
||||||
|
"outer",
|
||||||
|
r#"import "inner" as i; fn val() { i::v() }"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
// Entry script runs as the inherited GROUP script (default_origin = group).
|
||||||
|
let resp = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "outer" as o; o::val()"#,
|
||||||
|
req_with_owner(app, ScriptOwner::Group(group)),
|
||||||
|
)
|
||||||
|
.expect("should execute");
|
||||||
|
assert_eq!(
|
||||||
|
resp.body,
|
||||||
|
serde_json::json!(42),
|
||||||
|
"group module's import must seal to the group's `inner`, not the app's trap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same registry, entered as an APP-owned script, reaches the app's
|
||||||
|
/// module — proving the fake distinguishes origins and the entry uses
|
||||||
|
/// `default_origin`.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn lexical_entry_origin_selects_app_module() {
|
||||||
|
let source = LexicalModuleSource::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
|
||||||
|
.await;
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
let resp = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "inner" as i; i::v()"#,
|
||||||
|
req_with_owner(app, ScriptOwner::App(app)),
|
||||||
|
)
|
||||||
|
.expect("should execute");
|
||||||
|
assert_eq!(resp.body, serde_json::json!(999));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// §5.5 extension points — resolver handling of the policy outcomes.
|
||||||
|
//
|
||||||
|
// `PolicyModuleSource` is a flat fake that overrides `resolve_policy`: an EP
|
||||||
|
// name resolves dynamically against the inheriting app; a non-EP name resolves
|
||||||
|
// lexically from the importing origin. This verifies the resolver maps
|
||||||
|
// Module/NoProvider/NotFound correctly. The full chain semantics (default body
|
||||||
|
// up-chain, nearest-declaration tie) are covered by the CLI journey tests.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct PolicyModuleSource {
|
||||||
|
modules: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
|
||||||
|
eps: Mutex<HashSet<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PolicyModuleSource {
|
||||||
|
fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self::default())
|
||||||
|
}
|
||||||
|
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) {
|
||||||
|
let (app_id, group_id) = match owner {
|
||||||
|
ScriptOwner::App(a) => (Some(a), None),
|
||||||
|
ScriptOwner::Group(g) => (None, Some(g)),
|
||||||
|
};
|
||||||
|
self.modules.lock().await.insert(
|
||||||
|
(owner, name.to_string()),
|
||||||
|
ModuleScript {
|
||||||
|
script_id: ScriptId::new(),
|
||||||
|
app_id,
|
||||||
|
group_id,
|
||||||
|
name: name.to_string(),
|
||||||
|
source: source.to_string(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async fn mark_ep(self: &Arc<Self>, name: &str) {
|
||||||
|
self.eps.lock().await.insert(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ModuleSource for PolicyModuleSource {
|
||||||
|
async fn resolve(
|
||||||
|
&self,
|
||||||
|
origin: ScriptOwner,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
|
Ok(self
|
||||||
|
.modules
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&(origin, name.to_string()))
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_policy(
|
||||||
|
&self,
|
||||||
|
origin: ScriptOwner,
|
||||||
|
inheriting_app: AppId,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<picloud_shared::ModuleResolution, ModuleSourceError> {
|
||||||
|
use picloud_shared::ModuleResolution;
|
||||||
|
if self.eps.lock().await.contains(name) {
|
||||||
|
// Extension point → dynamic, resolved against the inheriting app.
|
||||||
|
return Ok(
|
||||||
|
match self.resolve(ScriptOwner::App(inheriting_app), name).await? {
|
||||||
|
Some(m) => ModuleResolution::Module(m),
|
||||||
|
None => ModuleResolution::NoProvider,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(match self.resolve(origin, name).await? {
|
||||||
|
Some(m) => ModuleResolution::Module(m),
|
||||||
|
None => ModuleResolution::NotFound,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An extension-point import binds the inheriting APP's module even though the
|
||||||
|
/// importing script's defining node is the group (dynamic override — the
|
||||||
|
/// inverse of the Phase 4b sealed/lexical import).
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn extension_point_resolves_app_override() {
|
||||||
|
let source = PolicyModuleSource::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
source.mark_ep("theme").await;
|
||||||
|
// App provides its own `theme`; group has none.
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::App(app), "theme", r#"fn color() { "red" }"#)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
// Entry runs as the inherited GROUP endpoint importing the EP `theme`.
|
||||||
|
let resp = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "theme" as t; t::color()"#,
|
||||||
|
req_with_owner(app, ScriptOwner::Group(group)),
|
||||||
|
)
|
||||||
|
.expect("should execute");
|
||||||
|
assert_eq!(resp.body, serde_json::json!("red"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An extension point with no provider for the app is a hard error.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn extension_point_without_provider_errors() {
|
||||||
|
let source = PolicyModuleSource::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
source.mark_ep("theme").await; // declared, but no module anywhere.
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
let err = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "theme" as t; t::color()"#,
|
||||||
|
req_with_owner(app, ScriptOwner::Group(group)),
|
||||||
|
)
|
||||||
|
.expect_err("missing provider must error");
|
||||||
|
let msg = format!("{err:?}").to_lowercase();
|
||||||
|
assert!(
|
||||||
|
msg.contains("no provider") || msg.contains("extension point"),
|
||||||
|
"expected a no-provider error, got {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-extension-point name still resolves lexically from the origin.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn non_extension_point_stays_lexical() {
|
||||||
|
let source = PolicyModuleSource::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::Group(group), "util", r#"fn v() { 7 }"#)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
let resp = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "util" as u; u::v()"#,
|
||||||
|
req_with_owner(app, ScriptOwner::Group(group)),
|
||||||
|
)
|
||||||
|
.expect("should execute");
|
||||||
|
assert_eq!(resp.body, serde_json::json!(7));
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ fn baseline_request() -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id: AppId::new(),
|
app_id: AppId::new(),
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
@@ -256,6 +257,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
@@ -499,8 +501,27 @@ async fn docs_bridge_preserves_cross_app_isolation() {
|
|||||||
v == ()
|
v == ()
|
||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
let body = run_script(engine, &reader_src, baseline_request(app_b)).await;
|
let body = run_script(engine.clone(), &reader_src, baseline_request(app_b)).await;
|
||||||
assert_eq!(body, json!(true));
|
assert_eq!(body, json!(true), "app B must not see app A's document");
|
||||||
|
|
||||||
|
// Positive control — without it this test has no teeth (an execution_id-keyed
|
||||||
|
// bridge would pass the negative above). `baseline_request` mints a fresh
|
||||||
|
// execution_id/script_id per call, so this re-read under app_a is a different
|
||||||
|
// execution than the writer; it must still find A's own doc.
|
||||||
|
let reader_a = format!(
|
||||||
|
r#"
|
||||||
|
let c = docs::collection("shared");
|
||||||
|
let v = c.get("{id_a_str}");
|
||||||
|
if v == () {{ "MISSING" }} else {{ v.data.from }}
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
let body = run_script(engine, &reader_a, baseline_request(app_a)).await;
|
||||||
|
assert_eq!(
|
||||||
|
body,
|
||||||
|
json!("a"),
|
||||||
|
"app A must read back its own document in a later execution — storage is \
|
||||||
|
keyed by app_id, not execution_id/script_id"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ fn engine_with(rec: Arc<RecordingEmail>) -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
@@ -65,6 +66,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
@@ -193,6 +194,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ fn engine_with(http: Arc<dyn HttpService>) -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
@@ -116,6 +117,7 @@ fn baseline_request(app_id: AppId, script_id: ScriptId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ impl InvokeService for FakeInvokeService {
|
|||||||
Ok(ResolvedScript {
|
Ok(ResolvedScript {
|
||||||
script_id: id,
|
script_id: id,
|
||||||
app_id: app,
|
app_id: app,
|
||||||
|
owner: None,
|
||||||
source,
|
source,
|
||||||
updated_at: Utc::now(),
|
updated_at: Utc::now(),
|
||||||
name,
|
name,
|
||||||
@@ -90,6 +91,7 @@ fn build_engine(svc: Arc<FakeInvokeService>) -> Arc<Engine> {
|
|||||||
Arc::new(NoopUsersService),
|
Arc::new(NoopUsersService),
|
||||||
Arc::new(NoopQueueService),
|
Arc::new(NoopQueueService),
|
||||||
svc,
|
svc,
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
let engine = Arc::new(Engine::new(Limits::default(), services));
|
let engine = Arc::new(Engine::new(Limits::default(), services));
|
||||||
engine.set_self_weak(Arc::downgrade(&engine));
|
engine.set_self_weak(Arc::downgrade(&engine));
|
||||||
@@ -113,6 +115,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
@@ -191,16 +194,23 @@ async fn invoke_callee_sees_incremented_depth() {
|
|||||||
#{ statusCode: 200, body: d }
|
#{ statusCode: 200, body: d }
|
||||||
"#
|
"#
|
||||||
.to_string();
|
.to_string();
|
||||||
|
// The caller is direct ingress (depth 0); the callee, re-entered via
|
||||||
|
// `invoke`, must see depth 1. Previously this test asserted NOTHING (`let _ =
|
||||||
|
// resp`) because `ctx.trigger_depth` wasn't exposed — so it would have passed
|
||||||
|
// even if `invoke` forwarded the depth unchanged (never incrementing the
|
||||||
|
// chain-depth counter that bounds runaway trigger loops). `ctx.trigger_depth`
|
||||||
|
// is now surfaced in build_ctx_map, so we can pin the increment.
|
||||||
let req = baseline_request(app);
|
let req = baseline_request(app);
|
||||||
|
assert_eq!(req.trigger_depth, 0, "the caller is direct ingress");
|
||||||
let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// ctx exposes trigger_depth? — not yet (it's not in build_ctx_map).
|
assert_eq!(
|
||||||
// Skip the strict assertion — the test still ensures the invoke
|
resp.body,
|
||||||
// chain didn't throw. (See HANDBACK §11 for cx.trigger_depth surface
|
json!(1),
|
||||||
// exposure as a v1.2 follow-up.)
|
"the invoke callee must see trigger_depth = caller + 1"
|
||||||
let _ = resp;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
|||||||
@@ -52,6 +52,27 @@ impl KvService for InMemoryKv {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn set_if(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
key: &str,
|
||||||
|
expected: Option<Value>,
|
||||||
|
new: Value,
|
||||||
|
) -> Result<bool, KvError> {
|
||||||
|
let mut data = self.data.lock().await;
|
||||||
|
let k = (cx.app_id, collection.to_string(), key.to_string());
|
||||||
|
let matches = match (&expected, data.get(&k)) {
|
||||||
|
(None, None) => true,
|
||||||
|
(Some(exp), Some(cur)) => exp == cur,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if matches {
|
||||||
|
data.insert(k, new);
|
||||||
|
}
|
||||||
|
Ok(matches)
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError> {
|
async fn delete(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.data
|
.data
|
||||||
@@ -114,6 +135,7 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
@@ -135,6 +157,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
@@ -165,6 +188,27 @@ async fn kv_set_then_get_round_trip() {
|
|||||||
assert_eq!(body, json!({ "n": 1 }));
|
assert_eq!(body, json!({ "n": 1 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn kv_set_if_compare_and_swap() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let app = AppId::new();
|
||||||
|
// `set_if(key, (), new)` inserts only when absent; `set_if(key, expected,
|
||||||
|
// new)` swaps only on match. Returns a bool each time.
|
||||||
|
let src = r#"
|
||||||
|
let c = kv::collection("counters");
|
||||||
|
let first = c.set_if("n", (), 1); // absent -> inserts, true
|
||||||
|
let dup = c.set_if("n", (), 2); // present -> false
|
||||||
|
let bad = c.set_if("n", 99, 3); // mismatch -> false
|
||||||
|
let good = c.set_if("n", 1, 3); // match -> swaps, true
|
||||||
|
#{ first: first, dup: dup, bad: bad, good: good, final: c.get("n") }
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine, src, baseline_request(app)).await;
|
||||||
|
assert_eq!(
|
||||||
|
body,
|
||||||
|
json!({ "first": true, "dup": false, "bad": false, "good": true, "final": 3 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn kv_get_missing_returns_unit() {
|
async fn kv_get_missing_returns_unit() {
|
||||||
let engine = make_engine();
|
let engine = make_engine();
|
||||||
@@ -267,6 +311,99 @@ async fn kv_bridge_preserves_cross_app_isolation() {
|
|||||||
let c = kv::collection("shared");
|
let c = kv::collection("shared");
|
||||||
c.get("k")
|
c.get("k")
|
||||||
"#;
|
"#;
|
||||||
let body = run_script(engine, reader, baseline_request(app_b)).await;
|
let body = run_script(engine.clone(), reader, baseline_request(app_b)).await;
|
||||||
assert_eq!(body, Value::Null);
|
assert_eq!(body, Value::Null, "app B must not see app A's value");
|
||||||
|
|
||||||
|
// Positive control — WITHOUT it this test has no teeth. `baseline_request`
|
||||||
|
// mints a fresh execution_id AND script_id each call, so this second run under
|
||||||
|
// app_a is a DIFFERENT execution/script than the writer. A bridge that keyed
|
||||||
|
// storage by execution_id or script_id (instead of app_id) would pass the
|
||||||
|
// negative above AND every single-execution round-trip, while app-scoped
|
||||||
|
// persistence was completely gone. This catches that: app A must still read
|
||||||
|
// back its own value across executions.
|
||||||
|
let body = run_script(engine, reader, baseline_request(app_a)).await;
|
||||||
|
assert_eq!(
|
||||||
|
body,
|
||||||
|
Value::from("from-a"),
|
||||||
|
"app A must read back its own value in a later execution — storage is keyed \
|
||||||
|
by app_id, not execution_id/script_id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- §9.4 M6: per-execution interceptor resolve cache ---------------------
|
||||||
|
|
||||||
|
/// An `InterceptorService` that resolves to an EMPTY chain (nothing hooked) but
|
||||||
|
/// counts every `resolve` call, so a test can prove the per-execution cache
|
||||||
|
/// collapses N same-`(service, op)` resolves into one.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct CountingInterceptors {
|
||||||
|
calls: std::sync::atomic::AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl picloud_shared::InterceptorService for CountingInterceptors {
|
||||||
|
async fn resolve(
|
||||||
|
&self,
|
||||||
|
_cx: &SdkCallCx,
|
||||||
|
_service: &str,
|
||||||
|
_op: &str,
|
||||||
|
) -> Result<picloud_shared::InterceptorChain, String> {
|
||||||
|
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
Ok(picloud_shared::InterceptorChain::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_engine_with_interceptors(ic: Arc<CountingInterceptors>) -> Arc<Engine> {
|
||||||
|
let services = Services::new(
|
||||||
|
Arc::new(InMemoryKv::default()),
|
||||||
|
Arc::new(NoopDocsService),
|
||||||
|
Arc::new(NoopDeadLetterService),
|
||||||
|
Arc::new(NoopEventEmitter),
|
||||||
|
Arc::new(NoopModuleSource),
|
||||||
|
Arc::new(NoopHttpService),
|
||||||
|
Arc::new(picloud_shared::NoopFilesService),
|
||||||
|
Arc::new(picloud_shared::NoopPubsubService),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
|
Arc::new(picloud_shared::NoopEmailService),
|
||||||
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
|
)
|
||||||
|
.with_interceptors(ic);
|
||||||
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// M6: within ONE execution, N `kv::set`s of the same `(service, op)` resolve
|
||||||
|
/// the interceptor chain at most once (the empty chain is cached), and a SECOND
|
||||||
|
/// execution starts fresh (the cache is cleared at the outermost boundary).
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn resolve_is_cached_per_execution() {
|
||||||
|
let ic = Arc::new(CountingInterceptors::default());
|
||||||
|
let engine = make_engine_with_interceptors(ic.clone());
|
||||||
|
let app = AppId::new();
|
||||||
|
|
||||||
|
// 25 sets in one execution — all the same (kv, set).
|
||||||
|
let src = r#"
|
||||||
|
let c = kv::collection("c");
|
||||||
|
let n = 0;
|
||||||
|
while n < 25 { c.set("k" + n, n); n += 1; }
|
||||||
|
"done"
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine.clone(), src, baseline_request(app)).await;
|
||||||
|
assert_eq!(body, Value::from("done"));
|
||||||
|
assert_eq!(
|
||||||
|
ic.calls.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"25 kv::sets in one execution must resolve the (kv, set) chain exactly once"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A second execution resolves again (the cache cleared at the outermost exit).
|
||||||
|
let body = run_script(engine, src, baseline_request(app)).await;
|
||||||
|
assert_eq!(body, Value::from("done"));
|
||||||
|
assert_eq!(
|
||||||
|
ic.calls.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
2,
|
||||||
|
"a fresh execution must not reuse the previous execution's resolve cache"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use serde_json::{json, Value};
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct RecordingPubsub {
|
struct RecordingPubsub {
|
||||||
last: Mutex<Option<(String, Value)>>,
|
last: Mutex<Option<(String, Value)>>,
|
||||||
|
count: std::sync::atomic::AtomicUsize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -32,6 +33,7 @@ impl PubsubService for RecordingPubsub {
|
|||||||
if topic.trim().is_empty() {
|
if topic.trim().is_empty() {
|
||||||
return Err(PubsubError::EmptyTopic);
|
return Err(PubsubError::EmptyTopic);
|
||||||
}
|
}
|
||||||
|
self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
*self.last.lock().unwrap() = Some((topic.to_string(), message));
|
*self.last.lock().unwrap() = Some((topic.to_string(), message));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -52,6 +54,7 @@ fn make_engine(svc: Arc<RecordingPubsub>) -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
@@ -73,6 +76,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
@@ -161,3 +165,40 @@ async fn publish_empty_topic_throws() {
|
|||||||
.expect("spawn_blocking should not panic");
|
.expect("spawn_blocking should not panic");
|
||||||
assert!(res.is_err(), "empty topic should throw");
|
assert!(res.is_err(), "empty topic should throw");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `PICLOUD_MAX_EMISSIONS_PER_EXECUTION` bounds a one-request outbox-amplification
|
||||||
|
/// DoS. Only the counter itself was unit-tested (`emit_budget`); this drives it
|
||||||
|
/// through the REAL `pubsub::publish_durable` SDK surface — i.e. it pins that the
|
||||||
|
/// charge_emission call is actually wired into publish, not just that the counter
|
||||||
|
/// works. Uses the DEFAULT budget (1000) so it needs no env mutation: a loop of
|
||||||
|
/// 1001 publishes must error, and no more than the budget may have reached the
|
||||||
|
/// service.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn publish_beyond_the_emission_budget_is_refused() {
|
||||||
|
let svc = Arc::new(RecordingPubsub::default());
|
||||||
|
let engine = make_engine(svc.clone());
|
||||||
|
// 1001 durable publishes in one execution — one past the default ceiling.
|
||||||
|
let src = r#"
|
||||||
|
let n = 0;
|
||||||
|
while n < 1001 { pubsub::publish_durable("t", #{ i: n }); n += 1; }
|
||||||
|
"#
|
||||||
|
.to_string();
|
||||||
|
let req = baseline_request(AppId::new());
|
||||||
|
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.expect("spawn_blocking should not panic");
|
||||||
|
|
||||||
|
let err = res.expect_err("exceeding the per-execution emission budget must error");
|
||||||
|
assert!(
|
||||||
|
format!("{err:?}").to_lowercase().contains("emission"),
|
||||||
|
"the error must name the emission budget, got: {err:?}"
|
||||||
|
);
|
||||||
|
// The rail actually stopped the amplification: the service saw at most the
|
||||||
|
// budget, not all 1001.
|
||||||
|
let seen = svc.count.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
|
assert!(
|
||||||
|
seen <= 1000,
|
||||||
|
"the service must not have received more than the 1000-emission budget, saw {seen}"
|
||||||
|
);
|
||||||
|
assert!(seen > 0, "some publishes should have landed before the cap");
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ fn make_engine(svc: Arc<RecordingQueue>) -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
svc,
|
svc,
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
@@ -87,6 +88,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ fn build_engine() -> Arc<Engine> {
|
|||||||
Arc::new(NoopUsersService),
|
Arc::new(NoopUsersService),
|
||||||
Arc::new(NoopQueueService),
|
Arc::new(NoopQueueService),
|
||||||
Arc::new(NoopInvokeService),
|
Arc::new(NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
@@ -51,6 +52,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
@@ -138,12 +140,40 @@ async fn retry_policy_clamps_visible_at_script_layer() {
|
|||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn retry_policy_rejects_bogus_backoff() {
|
async fn retry_policy_rejects_bogus_backoff() {
|
||||||
let engine = build_engine();
|
let engine = build_engine();
|
||||||
|
|
||||||
|
// Pin the REASON. A bare `is_err()` would pass if `retry::policy` were not
|
||||||
|
// registered at all (ErrorFunctionNotFound), or on a typo in the script
|
||||||
|
// source, or if EVERY policy were rejected — none of which is "an invalid
|
||||||
|
// backoff is rejected". The message must name `backoff`.
|
||||||
let src = r#"retry::policy(#{ backoff: "bogus" });"#.to_string();
|
let src = r#"retry::policy(#{ backoff: "bogus" });"#.to_string();
|
||||||
let req = baseline_request(AppId::new());
|
let req = baseline_request(AppId::new());
|
||||||
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
let err = tokio::task::spawn_blocking({
|
||||||
|
let engine = engine.clone();
|
||||||
|
move || engine.execute(&src, req)
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap()
|
||||||
assert!(res.is_err());
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(
|
||||||
|
err.to_lowercase().contains("backoff"),
|
||||||
|
"the rejection must be about the backoff value specifically, got: {err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Control: the SAME call with a VALID backoff succeeds — proving `retry::policy`
|
||||||
|
// is registered and does not reject everything, so the failure above is
|
||||||
|
// specific to the bad value.
|
||||||
|
let ok_src = r#"
|
||||||
|
let p = retry::policy(#{ backoff: "constant", max_attempts: 2, base_ms: 1 });
|
||||||
|
retry::run(p, || 7)
|
||||||
|
"#
|
||||||
|
.to_string();
|
||||||
|
let req = baseline_request(AppId::new());
|
||||||
|
let resp = tokio::task::spawn_blocking(move || engine.execute(&ok_src, req))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("a valid backoff must be accepted");
|
||||||
|
assert_eq!(resp.body, serde_json::json!(7));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
@@ -126,6 +127,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
@@ -120,6 +121,7 @@ fn request(app_id: AppId, with_principal: bool) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: with_principal.then(|| Principal {
|
principal: with_principal.then(|| Principal {
|
||||||
user_id: AdminUserId::new(),
|
user_id: AdminUserId::new(),
|
||||||
instance_role: InstanceRole::Owner,
|
instance_role: InstanceRole::Owner,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ fn baseline_request() -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id: AppId::new(),
|
app_id: AppId::new(),
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
|
|||||||
@@ -41,4 +41,5 @@ data-encoding.workspace = true
|
|||||||
lettre.workspace = true
|
lettre.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
picloud-test-support.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- G1 (E2E #2 "Stash"): trigger executions were invisible to `pic logs`.
|
||||||
|
--
|
||||||
|
-- Only HTTP-route executions ever wrote an `execution_logs` row; queue,
|
||||||
|
-- cron, dead-letter, and `invoke()` runs left no trace, so background
|
||||||
|
-- workers were observable only via dead-letters (failures) or their own
|
||||||
|
-- side effects. The dispatcher now logs every trigger run too — this
|
||||||
|
-- column records which kind of event dispatched each execution so the
|
||||||
|
-- logs surface can show, and filter by, the origin.
|
||||||
|
--
|
||||||
|
-- DEFAULT 'http' backfills every pre-existing row: before this change the
|
||||||
|
-- only thing that logged was the HTTP path, so 'http' is correct history.
|
||||||
|
-- The CHECK list mirrors `manager-core::OutboxSourceKind` /
|
||||||
|
-- `shared::ExecutionSource`; keep all three in sync.
|
||||||
|
|
||||||
|
ALTER TABLE execution_logs
|
||||||
|
ADD COLUMN source TEXT NOT NULL DEFAULT 'http'
|
||||||
|
CHECK (source IN (
|
||||||
|
'http', 'kv', 'docs', 'dead_letter', 'cron',
|
||||||
|
'files', 'pubsub', 'email', 'invoke', 'queue'
|
||||||
|
));
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- H1 (re-review of the S6 reserved-path fix): the reserved-prefix check is
|
||||||
|
-- now case-insensitive, both at route creation AND when the route table is
|
||||||
|
-- compiled at boot. Routes created before the fix — while validation was
|
||||||
|
-- case-sensitive — could hold paths like `/API/v2/x`, `/Admin/x`, or
|
||||||
|
-- `/HEALTHZ`. `compile_routes` now skips such rows with a warning instead of
|
||||||
|
-- aborting startup (so an un-upgraded boot can't be bricked), but those
|
||||||
|
-- routes violate the reserved namespace and can never be served safely, so
|
||||||
|
-- sweep them here on upgrade.
|
||||||
|
--
|
||||||
|
-- Mirrors `orchestrator-core::routing::pattern::check_reserved` exactly,
|
||||||
|
-- case-insensitively: a path is reserved if its lowercased form equals one
|
||||||
|
-- of the bare names (`/api` `/admin` `/healthz` `/version`) or starts with
|
||||||
|
-- one of the prefixes. Note `/api/` and `/admin/` reserve on the trailing
|
||||||
|
-- slash, while `/healthz` and `/version` reserve on bare prefix — matching
|
||||||
|
-- the RESERVED_PATH_PREFIXES list. (Idempotent: a no-op once swept.)
|
||||||
|
|
||||||
|
DELETE FROM routes
|
||||||
|
WHERE lower(path) IN ('/api', '/admin', '/healthz', '/version')
|
||||||
|
OR lower(path) LIKE '/api/%'
|
||||||
|
OR lower(path) LIKE '/admin/%'
|
||||||
|
OR lower(path) LIKE '/healthz%'
|
||||||
|
OR lower(path) LIKE '/version%';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Three-state `enabled` lifecycle (blueprint §4.3), scripts + routes half.
|
||||||
|
-- Triggers already carry `enabled` (0008) honored at match/schedule time;
|
||||||
|
-- this adds the same toggle to scripts and routes. A disabled script is not
|
||||||
|
-- invocable; a disabled route 404s (indistinguishable from absent). Default
|
||||||
|
-- TRUE so every existing row stays active — no behavior change on migrate.
|
||||||
|
|
||||||
|
ALTER TABLE scripts ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
|
ALTER TABLE routes ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
29
crates/manager-core/migrations/0046_trigger_name.sql
Normal file
29
crates/manager-core/migrations/0046_trigger_name.sql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-- Trigger `name` (§4.5): an explicit per-app identifier that becomes the
|
||||||
|
-- manifest merge/upsert key (Step B switches the apply diff to key on it).
|
||||||
|
-- Until now triggers were identified only by their per-kind semantic tuple,
|
||||||
|
-- so the declarative tool could only Create/Delete them, never Update.
|
||||||
|
--
|
||||||
|
-- Backfill existing rows with `{kind}-{n}` (n = per-(app,kind) sequence by
|
||||||
|
-- creation order) — the spec-sanctioned fallback when there's no cleaner
|
||||||
|
-- entity token. Then enforce NOT NULL + UNIQUE(app_id, name).
|
||||||
|
|
||||||
|
-- A `gen_random_uuid()` default keeps existing INSERTs (which don't yet
|
||||||
|
-- supply a name) valid and unique — the manager-core write paths start
|
||||||
|
-- providing meaningful names in the follow-up; new rows until then get a
|
||||||
|
-- harmless unique placeholder.
|
||||||
|
ALTER TABLE triggers
|
||||||
|
ADD COLUMN name TEXT NOT NULL DEFAULT gen_random_uuid()::text;
|
||||||
|
|
||||||
|
-- Rewrite the just-defaulted existing rows to the readable `{kind}-{n}` form.
|
||||||
|
UPDATE triggers t
|
||||||
|
SET name = sub.nm
|
||||||
|
FROM (
|
||||||
|
SELECT id,
|
||||||
|
kind || '-' || row_number() OVER (
|
||||||
|
PARTITION BY app_id, kind ORDER BY created_at, id
|
||||||
|
) AS nm
|
||||||
|
FROM triggers
|
||||||
|
) sub
|
||||||
|
WHERE t.id = sub.id;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX triggers_app_name_uniq ON triggers (app_id, name);
|
||||||
82
crates/manager-core/migrations/0047_groups.sql
Normal file
82
crates/manager-core/migrations/0047_groups.sql
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
-- Phase 2: groups as a pure org / RBAC / UI container — see
|
||||||
|
-- docs/design/groups-and-project-tool.md §5, §9.
|
||||||
|
--
|
||||||
|
-- Groups form a GitLab-like, single-parent tree ABOVE apps. Phase 2 adds
|
||||||
|
-- the tree, hierarchy-aware membership, and structural-mutation safety —
|
||||||
|
-- but NO group-owned resources yet (scripts/vars/secrets stay app-owned;
|
||||||
|
-- that is Phase 3). The only data-plane touch is apps gaining a parent
|
||||||
|
-- pointer.
|
||||||
|
--
|
||||||
|
-- Adoption (§9): every existing app must have a parent from day one so
|
||||||
|
-- resolution always terminates. This migration seeds a single instance
|
||||||
|
-- root group and reparents every app under it, then promotes
|
||||||
|
-- apps.group_id to NOT NULL.
|
||||||
|
|
||||||
|
CREATE TABLE groups (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- Single parent keeps inheritance acyclic and resolution
|
||||||
|
-- deterministic. NULL parent = a root node. RESTRICT (never CASCADE):
|
||||||
|
-- deleting a non-empty group is refused so descendant apps and their
|
||||||
|
-- isolated data can't be destroyed implicitly (§5.6). The ancestor-walk
|
||||||
|
-- cycle guard that keeps this acyclic lives in manager-core (a SQL
|
||||||
|
-- CHECK can't express it).
|
||||||
|
parent_id UUID REFERENCES groups(id) ON DELETE RESTRICT,
|
||||||
|
-- Instance-global identifier, frozen at creation. A rename/reparent
|
||||||
|
-- updates the display name/path but NEVER rewrites the slug, so the
|
||||||
|
-- deployment key stays stable and external references don't break.
|
||||||
|
-- Format validation lives in Rust handlers (same rule as app slugs).
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- Per-subtree structure version (§6): bumped on every structural
|
||||||
|
-- mutation of this node (reparent/rename/delete) so a future CLI/
|
||||||
|
-- orchestrator can detect structural drift. NOT an authz input —
|
||||||
|
-- authorization is resolved live every request.
|
||||||
|
structure_version BIGINT NOT NULL DEFAULT 1,
|
||||||
|
-- §7 ownership seam — the project-root that manages this node. Inert
|
||||||
|
-- in Phase 2 (no projects table yet); nullable, no FK.
|
||||||
|
owner_project UUID,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX groups_parent_id_idx ON groups (parent_id);
|
||||||
|
|
||||||
|
-- Per-(user, group) explicit grant, mirroring app_members. Inherited
|
||||||
|
-- membership (GitLab-style) is resolved in code by walking ancestors: a
|
||||||
|
-- group_admin on any ancestor is implicitly app_admin on every app and
|
||||||
|
-- subgroup beneath it. Roles reuse the SAME three literals as app_members
|
||||||
|
-- so AppRole round-trips with zero mapping and the authz rank table
|
||||||
|
-- covers both tables.
|
||||||
|
CREATE TABLE group_members (
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('app_admin', 'editor', 'viewer')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (group_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Hot path is the authz ancestor walk "what groups does this user have a
|
||||||
|
-- role on?" plus the per-group member list.
|
||||||
|
CREATE INDEX group_members_user_id_idx ON group_members (user_id);
|
||||||
|
|
||||||
|
-- Add the parent pointer to apps (nullable for the backfill window).
|
||||||
|
ALTER TABLE apps ADD COLUMN group_id UUID;
|
||||||
|
|
||||||
|
-- Seed a single instance root group and reparent every existing app under
|
||||||
|
-- it. App slugs are already instance-global, so no slug rewrite is needed
|
||||||
|
-- — the parent pointer is new metadata layered on top.
|
||||||
|
WITH root_group AS (
|
||||||
|
INSERT INTO groups (slug, name, description)
|
||||||
|
VALUES ('root', 'Root', 'The instance root group — parent of all apps created before groups landed.')
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
UPDATE apps SET group_id = (SELECT id FROM root_group);
|
||||||
|
|
||||||
|
-- Every app now has a parent; promote to NOT NULL + FK. RESTRICT so a
|
||||||
|
-- group with apps can't be deleted out from under them (§5.6).
|
||||||
|
ALTER TABLE apps ALTER COLUMN group_id SET NOT NULL;
|
||||||
|
ALTER TABLE apps
|
||||||
|
ADD CONSTRAINT apps_group_id_fk FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
CREATE INDEX apps_group_id_idx ON apps (group_id);
|
||||||
47
crates/manager-core/migrations/0048_vars.sql
Normal file
47
crates/manager-core/migrations/0048_vars.sql
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
-- Phase 3: group-inherited config — the `vars` table + an app environment
|
||||||
|
-- marker. See docs/design/groups-and-project-tool.md §3, §5.1.
|
||||||
|
--
|
||||||
|
-- `vars` is the net-new, env-scoped configuration layer (greenfield — there
|
||||||
|
-- is no env-agnostic config today, only `secrets`). A var is owned by
|
||||||
|
-- exactly one group OR one app, optionally scoped to an environment, and
|
||||||
|
-- resolved down the tree (§3): env-filter first, then nearest level wins.
|
||||||
|
--
|
||||||
|
-- "An environment is an app": each env is already a distinct app row. To
|
||||||
|
-- env-filter group-level `@E` values, the resolver must know which env an
|
||||||
|
-- app represents — recorded here on `apps.environment` (NULL = env-agnostic,
|
||||||
|
-- set at app-create from the CLI's known env).
|
||||||
|
|
||||||
|
ALTER TABLE apps ADD COLUMN environment TEXT;
|
||||||
|
|
||||||
|
CREATE TABLE vars (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- Polymorphic owner: exactly one of the two FKs is set. Real FKs (not a
|
||||||
|
-- bare owner_id) so ON DELETE CASCADE works per owner kind and a dangling
|
||||||
|
-- owner is impossible.
|
||||||
|
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vars_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||||
|
-- '*' = env-agnostic; otherwise an env name matched against
|
||||||
|
-- apps.environment. NOT NULL with a '*' sentinel so the uniqueness
|
||||||
|
-- indexes are total (a NULL scope would break UNIQUE).
|
||||||
|
environment_scope TEXT NOT NULL DEFAULT '*',
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
-- JSONB per the v1.1 data-plane convention. A tombstone (deletion of an
|
||||||
|
-- inherited key, §3) is the explicit boolean below, NOT value = 'null'
|
||||||
|
-- — JSON null is a legitimate value and must stay distinguishable.
|
||||||
|
value JSONB NOT NULL,
|
||||||
|
is_tombstone BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One row per (owner, scope, key). Partial unique indexes because the owner
|
||||||
|
-- is split across two nullable columns.
|
||||||
|
CREATE UNIQUE INDEX vars_group_uidx
|
||||||
|
ON vars (group_id, environment_scope, key) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX vars_app_uidx
|
||||||
|
ON vars (app_id, environment_scope, key) WHERE app_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX vars_group_id_idx ON vars (group_id) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE INDEX vars_app_id_idx ON vars (app_id) WHERE app_id IS NOT NULL;
|
||||||
51
crates/manager-core/migrations/0049_group_secrets.sql
Normal file
51
crates/manager-core/migrations/0049_group_secrets.sql
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
-- Phase 3 (v1.1.9): group-owned, environment-scoped secrets.
|
||||||
|
--
|
||||||
|
-- Until now `secrets` was strictly per-app: PK `(app_id, name)`, one
|
||||||
|
-- envelope per app. Phase 3 makes secrets inheritable down the group tree
|
||||||
|
-- (blueprint §11 bullet 3 / docs/design §3), exactly like `vars` (0048):
|
||||||
|
-- a secret may be owned by an app OR an ancestor group, and a descendant
|
||||||
|
-- app resolves the nearest one, environment-filtered.
|
||||||
|
--
|
||||||
|
-- Reshape (mirrors `vars`):
|
||||||
|
-- * `group_id` — nullable FK→groups, CASCADE (a deleted group drops its
|
||||||
|
-- secrets, same as its vars).
|
||||||
|
-- * `app_id` — made nullable (was NOT NULL); the exactly-one CHECK now
|
||||||
|
-- enforces "owned by an app XOR a group".
|
||||||
|
-- * `environment_scope` — `'*'` (env-agnostic) or a concrete env name;
|
||||||
|
-- the resolver filters on it. Existing rows backfill to `'*'`, so every
|
||||||
|
-- current app secret stays env-agnostic and resolves unchanged.
|
||||||
|
-- * PK `(app_id, name)` → two PARTIAL unique indexes, one per owner, both
|
||||||
|
-- keyed `(owner, environment_scope, name)`.
|
||||||
|
--
|
||||||
|
-- CRYPTO INVARIANT (audit 2026-06-11 H-D1): the v1 AAD is
|
||||||
|
-- `secret:{app_id}:{name}` for app secrets and `secret:group:{group_id}:{name}`
|
||||||
|
-- for group secrets — it does NOT include `environment_scope`. Adding the
|
||||||
|
-- column therefore leaves every existing ciphertext decryptable byte-for-byte;
|
||||||
|
-- the app-owner AAD is unchanged and the group namespace is disjoint.
|
||||||
|
|
||||||
|
ALTER TABLE secrets
|
||||||
|
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
ADD COLUMN environment_scope TEXT NOT NULL DEFAULT '*';
|
||||||
|
|
||||||
|
-- Drop the old composite PK first: `app_id` cannot lose NOT NULL while it
|
||||||
|
-- is a primary-key column.
|
||||||
|
ALTER TABLE secrets
|
||||||
|
DROP CONSTRAINT secrets_pkey;
|
||||||
|
|
||||||
|
ALTER TABLE secrets
|
||||||
|
ALTER COLUMN app_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE secrets
|
||||||
|
ADD CONSTRAINT secrets_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||||
|
|
||||||
|
-- One secret per (owner, env, name). Partial so each owner column only
|
||||||
|
-- constrains its own rows; the resolver and every upsert restate the
|
||||||
|
-- predicate as the ON CONFLICT arbiter.
|
||||||
|
CREATE UNIQUE INDEX secrets_app_uidx
|
||||||
|
ON secrets (app_id, environment_scope, name) WHERE app_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX secrets_group_uidx
|
||||||
|
ON secrets (group_id, environment_scope, name) WHERE group_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Owner lookup index for the group side (the app side keeps idx_secrets_app).
|
||||||
|
CREATE INDEX idx_secrets_group ON secrets (group_id) WHERE group_id IS NOT NULL;
|
||||||
40
crates/manager-core/migrations/0050_group_scripts.sql
Normal file
40
crates/manager-core/migrations/0050_group_scripts.sql
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
-- Phase 4 (v1.2 Hierarchies): group-owned scripts.
|
||||||
|
--
|
||||||
|
-- Until now every script was owned by exactly one app (`app_id NOT NULL`,
|
||||||
|
-- ON DELETE RESTRICT). Phase 4 lets a script be owned by a GROUP instead, so
|
||||||
|
-- it is shared by every descendant app — resolved by name, nearest-owner
|
||||||
|
-- wins (CoW: an app's own script of the same name shadows the inherited one),
|
||||||
|
-- exactly like `vars`/`secrets` (0048/0049).
|
||||||
|
--
|
||||||
|
-- Phase 4-LITE scope: group-owned ENDPOINT scripts only. Group modules and
|
||||||
|
-- the origin-aware (lexical) import resolver are deferred to Phase 4b, so a
|
||||||
|
-- group-owned script must be self-contained (enforced in the service layer).
|
||||||
|
--
|
||||||
|
-- Reshape (mirrors vars/secrets, but RESTRICT not CASCADE — code is not data,
|
||||||
|
-- a group can't be deleted out from under the scripts it owns, matching the
|
||||||
|
-- existing app_id RESTRICT and the §5.6 delete=RESTRICT rule):
|
||||||
|
-- * `group_id` — nullable FK→groups, ON DELETE RESTRICT.
|
||||||
|
-- * `app_id` — made nullable (was NOT NULL); the exactly-one CHECK now
|
||||||
|
-- enforces "owned by an app XOR a group".
|
||||||
|
-- * the per-app unique name index becomes two partial indexes, one per owner.
|
||||||
|
|
||||||
|
ALTER TABLE scripts
|
||||||
|
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
ALTER TABLE scripts
|
||||||
|
ALTER COLUMN app_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE scripts
|
||||||
|
ADD CONSTRAINT scripts_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||||
|
|
||||||
|
-- Per-owner, case-insensitive name uniqueness. Partial so each owner column
|
||||||
|
-- only constrains its own rows; existing app rows (group_id NULL) keep the
|
||||||
|
-- exact same (app_id, LOWER(name)) uniqueness they had under the old index.
|
||||||
|
DROP INDEX scripts_name_uidx;
|
||||||
|
CREATE UNIQUE INDEX scripts_app_name_uidx
|
||||||
|
ON scripts (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX scripts_group_name_uidx
|
||||||
|
ON scripts (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX scripts_group_id_idx ON scripts (group_id) WHERE group_id IS NOT NULL;
|
||||||
40
crates/manager-core/migrations/0051_extension_points.sql
Normal file
40
crates/manager-core/migrations/0051_extension_points.sql
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
-- §5.5 (v1.2 Hierarchies): extension-point markers — opt-in module polymorphism.
|
||||||
|
--
|
||||||
|
-- An extension point marks a module NAME at a node (app or group) as one
|
||||||
|
-- descendants are *expected* to provide or override. Imports of an EP name
|
||||||
|
-- resolve DYNAMICALLY against the inheriting app's view (the app can supply
|
||||||
|
-- its own module), instead of the Phase 4b lexical default (sealed to the
|
||||||
|
-- importing script's defining node). See docs §5.5.
|
||||||
|
--
|
||||||
|
-- This table holds only the MARKER (owner, name) — it is pure declaration,
|
||||||
|
-- structurally identical to a `secrets` name. The optional DEFAULT BODY is
|
||||||
|
-- just a co-located `kind = 'module'` script of the same name at this node
|
||||||
|
-- (Phase 4b already stores/resolves/caches those); there is no body column
|
||||||
|
-- here. So a marker is config, not code → ON DELETE CASCADE (unlike the
|
||||||
|
-- module body's RESTRICT in 0050).
|
||||||
|
--
|
||||||
|
-- Ownership is polymorphic (mirrors vars/secrets/scripts): exactly one of
|
||||||
|
-- (app_id, group_id) is set, with per-owner partial-unique LOWER(name)
|
||||||
|
-- indexes for case-insensitive uniqueness.
|
||||||
|
|
||||||
|
CREATE TABLE extension_points (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT extension_points_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One marker per (owner, name), case-insensitive. Partial because the owner
|
||||||
|
-- is split across two nullable columns.
|
||||||
|
CREATE UNIQUE INDEX extension_points_group_uidx
|
||||||
|
ON extension_points (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX extension_points_app_uidx
|
||||||
|
ON extension_points (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Lookup indexes for the resolver's chain join + list-by-owner.
|
||||||
|
CREATE INDEX extension_points_group_id_idx ON extension_points (group_id) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE INDEX extension_points_app_id_idx ON extension_points (app_id) WHERE app_id IS NOT NULL;
|
||||||
47
crates/manager-core/migrations/0052_group_collections.sql
Normal file
47
crates/manager-core/migrations/0052_group_collections.sql
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
-- §11.6 (v1.2 Hierarchies): group-collection registry — shared cross-app data.
|
||||||
|
--
|
||||||
|
-- A row marks a collection NAME at a node as group-SHARED: every app in the
|
||||||
|
-- owning group's subtree may read it (and, with an authenticated editor+,
|
||||||
|
-- write it) via the explicit `kv::shared_collection("name")` SDK handle. Resolution is
|
||||||
|
-- nearest-ancestor-group-wins, walking the reading app's chain — that ancestry
|
||||||
|
-- walk is the isolation boundary (a foreign app's chain never contains the
|
||||||
|
-- owning group, so the name simply does not resolve). See docs §11.6.
|
||||||
|
--
|
||||||
|
-- This table holds only the MARKER (owner, name, kind) — the data itself lives
|
||||||
|
-- in a per-kind storage table (`group_kv_entries`, 0053, for kind='kv'). It is
|
||||||
|
-- pure declaration, structurally identical to an `extension_points` marker
|
||||||
|
-- (0051). A marker is config, not code → ON DELETE CASCADE.
|
||||||
|
--
|
||||||
|
-- Ownership is polymorphic (mirrors vars/secrets/scripts/extension_points):
|
||||||
|
-- exactly one of (app_id, group_id) is set. MVP authoring is restricted to
|
||||||
|
-- GROUP owners at the manifest layer (an app-declared shared collection is
|
||||||
|
-- degenerate — only that app would read it); the polymorphic shape keeps the
|
||||||
|
-- owner-generic reconcile path uniform with the other inheritable kinds.
|
||||||
|
--
|
||||||
|
-- `kind` is the storage discriminator. Only 'kv' ships in the MVP; the column
|
||||||
|
-- + CHECK exist now so docs/files/topics/queue slot in later without a
|
||||||
|
-- migration, and so a future `docs` collection of the same name is a distinct
|
||||||
|
-- row (the unique index covers `kind`).
|
||||||
|
|
||||||
|
CREATE TABLE group_collections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT group_collections_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'kv' CHECK (kind IN ('kv')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One marker per (owner, name, kind), case-insensitive. Partial because the
|
||||||
|
-- owner is split across two nullable columns.
|
||||||
|
CREATE UNIQUE INDEX group_collections_group_uidx
|
||||||
|
ON group_collections (group_id, LOWER(name), kind) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX group_collections_app_uidx
|
||||||
|
ON group_collections (app_id, LOWER(name), kind) WHERE app_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Lookup indexes for the resolver's chain join + list-by-owner.
|
||||||
|
CREATE INDEX group_collections_group_id_idx ON group_collections (group_id) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE INDEX group_collections_app_id_idx ON group_collections (app_id) WHERE app_id IS NOT NULL;
|
||||||
30
crates/manager-core/migrations/0053_group_kv_entries.sql
Normal file
30
crates/manager-core/migrations/0053_group_kv_entries.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- §11.6 (v1.2 Hierarchies): group-KV storage — the shared read/write data for
|
||||||
|
-- a collection declared group-shared in `group_collections` (0052, kind='kv').
|
||||||
|
--
|
||||||
|
-- Identity tuple `(group_id, collection, key)`. Unlike per-app `kv_entries`
|
||||||
|
-- (0007) this is keyed by the OWNING GROUP, not by app — the whole point is
|
||||||
|
-- that many apps in the group's subtree share one store. There is no `app_id`
|
||||||
|
-- column: a shared row belongs to the group, not to whichever app wrote it.
|
||||||
|
--
|
||||||
|
-- `value` is JSONB, mirroring `kv_entries`. The reading/writing app is resolved
|
||||||
|
-- to its owning group at the service layer (walking the app's ancestor chain);
|
||||||
|
-- this table only ever sees a concrete `group_id`.
|
||||||
|
--
|
||||||
|
-- ON DELETE CASCADE on group_id: the shared store is DATA, so it dies with its
|
||||||
|
-- owning group — like `vars`/`secrets` config CASCADE, deliberately UNLIKE the
|
||||||
|
-- `scripts` (0050) RESTRICT (code is not data). Deleting an app does NOT touch
|
||||||
|
-- this table (the data is the group's, and survives the app's departure).
|
||||||
|
|
||||||
|
CREATE TABLE group_kv_entries (
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
collection TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (group_id, collection, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Supports list-by-collection (keyset pagination); the PK already covers
|
||||||
|
-- (group_id, collection) as a prefix but the explicit index makes intent clear.
|
||||||
|
CREATE INDEX idx_group_kv_entries_group_collection ON group_kv_entries (group_id, collection);
|
||||||
33
crates/manager-core/migrations/0054_group_docs.sql
Normal file
33
crates/manager-core/migrations/0054_group_docs.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- §11.6 (cont., v1.2 Hierarchies): group-shared DOCS collections.
|
||||||
|
--
|
||||||
|
-- Extends the shared-collection machinery (0052 registry + 0053 group_kv_entries)
|
||||||
|
-- from KV to the queryable-JSON `docs` store. The registry's `kind`
|
||||||
|
-- discriminator was built for exactly this — widen its CHECK to admit 'docs',
|
||||||
|
-- and add a group-keyed storage table mirroring `docs` (0013).
|
||||||
|
|
||||||
|
-- Widen the marker kind allow-list. The constraint was an inline column CHECK,
|
||||||
|
-- so Postgres auto-named it `group_collections_kind_check`.
|
||||||
|
ALTER TABLE group_collections
|
||||||
|
DROP CONSTRAINT group_collections_kind_check,
|
||||||
|
ADD CONSTRAINT group_collections_kind_check CHECK (kind IN ('kv', 'docs'));
|
||||||
|
|
||||||
|
-- Group-keyed docs store. Identity tuple `(group_id, collection, id)` — keyed
|
||||||
|
-- by the OWNING GROUP, not an app (the shared rows belong to the group). `id`
|
||||||
|
-- is a server-generated UUID, as in `docs`. CASCADE on group delete (data dies
|
||||||
|
-- with its group; an app delete leaves it), matching group_kv_entries (0053).
|
||||||
|
CREATE TABLE group_docs (
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
collection TEXT NOT NULL,
|
||||||
|
id UUID NOT NULL,
|
||||||
|
data JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (group_id, collection, id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- "all docs in group X / collection Y" — mirrors idx_docs_app_collection (0013).
|
||||||
|
CREATE INDEX idx_group_docs_group_collection ON group_docs (group_id, collection);
|
||||||
|
|
||||||
|
-- GIN on JSONB (jsonb_path_ops) for the find DSL's equality/containment, same
|
||||||
|
-- as idx_docs_data_gin (0013).
|
||||||
|
CREATE INDEX idx_group_docs_data_gin ON group_docs USING GIN (data jsonb_path_ops);
|
||||||
37
crates/manager-core/migrations/0055_group_files.sql
Normal file
37
crates/manager-core/migrations/0055_group_files.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
-- §11.6 (cont., v1.2 Hierarchies): group-shared FILES collections.
|
||||||
|
--
|
||||||
|
-- Extends the shared-collection machinery (0052 registry + the per-kind stores
|
||||||
|
-- 0053 group_kv_entries / 0054 group_docs) from KV/docs to the filesystem-backed
|
||||||
|
-- `files` blob store. The registry's `kind` discriminator was built for exactly
|
||||||
|
-- this — widen its CHECK to admit 'files', and add a group-keyed metadata table
|
||||||
|
-- mirroring `files` (0018).
|
||||||
|
|
||||||
|
-- Widen the marker kind allow-list (auto-named `group_collections_kind_check`,
|
||||||
|
-- per 0052's inline column CHECK; 0054 widened it to ('kv','docs')).
|
||||||
|
ALTER TABLE group_collections
|
||||||
|
DROP CONSTRAINT group_collections_kind_check,
|
||||||
|
ADD CONSTRAINT group_collections_kind_check CHECK (kind IN ('kv', 'docs', 'files'));
|
||||||
|
|
||||||
|
-- Group-keyed file metadata. Identity tuple `(group_id, collection, id)` — keyed
|
||||||
|
-- by the OWNING GROUP, not an app (the shared blobs belong to the group). The
|
||||||
|
-- bytes live on disk at
|
||||||
|
-- <PICLOUD_FILES_ROOT>/files/groups/<group_id>/<collection>/<id[0:2]>/<id>
|
||||||
|
-- (a `groups/` infix disjoint from the per-app `files/<app_id>/...` subtree, so
|
||||||
|
-- the orphan sweeper covers both with one walk). CASCADE on group delete (the
|
||||||
|
-- metadata dies with its group; an app delete leaves it), matching 0053/0054.
|
||||||
|
CREATE TABLE group_files (
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
collection TEXT NOT NULL,
|
||||||
|
id UUID NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
checksum_sha256 TEXT NOT NULL, -- hex, 64 chars, lowercase
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (group_id, collection, id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- List + cursor pagination scans by (group_id, collection) — mirrors
|
||||||
|
-- idx_files_app_collection (0018).
|
||||||
|
CREATE INDEX idx_group_files_group_collection ON group_files (group_id, collection);
|
||||||
49
crates/manager-core/migrations/0056_group_triggers.sql
Normal file
49
crates/manager-core/migrations/0056_group_triggers.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
-- §11 tail (v1.2 Hierarchies): group TRIGGER templates.
|
||||||
|
--
|
||||||
|
-- Until now every trigger was owned by exactly one app (`app_id NOT NULL`).
|
||||||
|
-- A group-owned trigger is a TEMPLATE: it is never dispatched directly, but
|
||||||
|
-- the dispatcher's hot-path match queries UNION it in for every descendant app
|
||||||
|
-- via the ancestor-chain CTE (live resolution, like vars/secrets/scripts and
|
||||||
|
-- §11.6 collections — no per-app materialized rows). It binds a group-owned
|
||||||
|
-- handler script and fires under each firing app's `app_id`.
|
||||||
|
--
|
||||||
|
-- Scope: EVENT kinds only (kv/docs/files/pubsub) — those are stateless at
|
||||||
|
-- dispatch. cron/queue/email carry per-instance state (last_fired_at, the
|
||||||
|
-- one-consumer advisory lock, a sealed inbound secret) and would need
|
||||||
|
-- materialization; they are rejected on a [group] at the manifest layer and
|
||||||
|
-- deferred. The CHECK here is deliberately NOT narrowed to event kinds: the
|
||||||
|
-- column allows any kind for forward-compat, the authoring gate is upstream.
|
||||||
|
--
|
||||||
|
-- Reshape mirrors 0050_group_scripts exactly (RESTRICT, not CASCADE — a
|
||||||
|
-- trigger template references a group script; a group can't be deleted out
|
||||||
|
-- from under it):
|
||||||
|
-- * `group_id` — nullable FK→groups, ON DELETE RESTRICT.
|
||||||
|
-- * `app_id` — made nullable; the exactly-one CHECK enforces app XOR group.
|
||||||
|
-- * per-app unique name index + dispatch index become per-owner partials.
|
||||||
|
|
||||||
|
ALTER TABLE triggers
|
||||||
|
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
ALTER TABLE triggers
|
||||||
|
ALTER COLUMN app_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE triggers
|
||||||
|
ADD CONSTRAINT triggers_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||||
|
|
||||||
|
-- Per-owner name uniqueness (partial so each owner column only constrains its
|
||||||
|
-- own rows). Existing app rows keep the exact (app_id, name) uniqueness.
|
||||||
|
DROP INDEX triggers_app_name_uniq;
|
||||||
|
CREATE UNIQUE INDEX triggers_app_name_uniq
|
||||||
|
ON triggers (app_id, name) WHERE app_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX triggers_group_name_uniq
|
||||||
|
ON triggers (group_id, name) WHERE group_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Per-owner dispatch hot-path index ("all enabled triggers of kind Y for
|
||||||
|
-- owner X"). The app index keeps its name + shape (now owner-partial); a
|
||||||
|
-- parallel group index serves the chain-union's group-template lookups.
|
||||||
|
DROP INDEX idx_triggers_app_kind_enabled;
|
||||||
|
CREATE INDEX idx_triggers_app_kind_enabled
|
||||||
|
ON triggers (app_id, kind) WHERE enabled = TRUE AND app_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_triggers_group_kind_enabled
|
||||||
|
ON triggers (group_id, kind) WHERE enabled = TRUE AND group_id IS NOT NULL;
|
||||||
48
crates/manager-core/migrations/0057_group_routes.sql
Normal file
48
crates/manager-core/migrations/0057_group_routes.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
-- §11 tail (v1.2 Hierarchies): group ROUTE templates.
|
||||||
|
--
|
||||||
|
-- Until now every route was owned by exactly one app (`app_id NOT NULL`).
|
||||||
|
-- A group-owned route is a TEMPLATE: it is never served directly, but the
|
||||||
|
-- in-memory RouteTable rebuild EXPANDS it into every descendant app's slice
|
||||||
|
-- via the ancestor chain (live resolution, like vars/secrets/scripts, §11.6
|
||||||
|
-- collections, and §11 trigger templates — no per-app materialized rows). It
|
||||||
|
-- binds a group-owned handler script and runs under each firing app's
|
||||||
|
-- `app_id`.
|
||||||
|
--
|
||||||
|
-- Unlike triggers (a SQL-per-event dispatch), routes are served from an
|
||||||
|
-- in-memory cache, so the inheritance is resolved at table-rebuild time, not
|
||||||
|
-- per request. The reshape, however, is identical: a polymorphic owner column.
|
||||||
|
--
|
||||||
|
-- Reshape mirrors 0056_group_triggers exactly (RESTRICT, not CASCADE — a route
|
||||||
|
-- template references a group script; a group can't be deleted out from under
|
||||||
|
-- it):
|
||||||
|
-- * `group_id` — nullable FK→groups, ON DELETE RESTRICT.
|
||||||
|
-- * `app_id` — made nullable; the exactly-one CHECK enforces app XOR group.
|
||||||
|
-- * the per-app unique binding index becomes per-owner partials.
|
||||||
|
|
||||||
|
ALTER TABLE routes
|
||||||
|
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
ALTER TABLE routes
|
||||||
|
ALTER COLUMN app_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE routes
|
||||||
|
ADD CONSTRAINT routes_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||||
|
|
||||||
|
-- Per-owner binding uniqueness (partial so each owner column only constrains
|
||||||
|
-- its own rows). Existing app rows keep the exact binding-tuple uniqueness; a
|
||||||
|
-- group can't declare two identical templates. An app declaring a binding
|
||||||
|
-- identical to an inherited group template is a deliberate SHADOW, resolved
|
||||||
|
-- at rebuild (nearest-owner-wins) — not a DB conflict.
|
||||||
|
DROP INDEX routes_unique_binding_idx;
|
||||||
|
CREATE UNIQUE INDEX routes_unique_binding_idx
|
||||||
|
ON routes (app_id, host_kind, host, path_kind, path, COALESCE(method, ''))
|
||||||
|
WHERE app_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX routes_group_binding_idx
|
||||||
|
ON routes (group_id, host_kind, host, path_kind, path, COALESCE(method, ''))
|
||||||
|
WHERE group_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- The expansion join matches routes by owner (`r.app_id = ac.owner_app OR
|
||||||
|
-- r.group_id = ac.owner_group`). The app side already has routes_app_id_idx;
|
||||||
|
-- add the parallel group lookup index.
|
||||||
|
CREATE INDEX routes_group_id_idx ON routes (group_id) WHERE group_id IS NOT NULL;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- §11 tail (v1.2 Hierarchies): per-app opt-out of inherited group templates.
|
||||||
|
--
|
||||||
|
-- A group TRIGGER or ROUTE template inherits to every descendant app. Until now
|
||||||
|
-- a descendant could SHADOW a template (declare its own identical binding) but
|
||||||
|
-- not DECLINE one. This marker records that a specific app opts OUT of a
|
||||||
|
-- specific inherited template — the template then stops resolving for that app
|
||||||
|
-- (and only that app; a sibling subtree is unaffected).
|
||||||
|
--
|
||||||
|
-- Coarse by REFERENCE (not row id — template ids churn on re-apply): a
|
||||||
|
-- suppression names a handler SCRIPT NAME (target_kind='trigger') or a PATH
|
||||||
|
-- (target_kind='route'). A reference may decline more than one inherited
|
||||||
|
-- template (a group that bound several to the same script/path). The dispatch
|
||||||
|
-- filters gate to group-owned rows, so an app can only decline what it
|
||||||
|
-- INHERITS — never its own trigger/route, never a sibling's.
|
||||||
|
--
|
||||||
|
-- App-only: only an app suppresses (a group would just not declare the
|
||||||
|
-- template). So `app_id NOT NULL` — no polymorphic owner. It is pure app config
|
||||||
|
-- (like `extension_points`), so ON DELETE CASCADE (not the code-owner RESTRICT).
|
||||||
|
|
||||||
|
CREATE TABLE template_suppressions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
target_kind TEXT NOT NULL CHECK (target_kind IN ('trigger', 'route')),
|
||||||
|
-- A handler script name (trigger) or a path (route). Matched
|
||||||
|
-- case-insensitively for triggers (as script-name resolution is
|
||||||
|
-- elsewhere) and exactly for routes; stored as authored.
|
||||||
|
reference TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One marker per (app, kind, reference) — re-apply is a NoOp.
|
||||||
|
CREATE UNIQUE INDEX template_suppressions_uidx
|
||||||
|
ON template_suppressions (app_id, target_kind, reference);
|
||||||
|
|
||||||
|
-- The trigger anti-join / route rebuild both look up by app_id.
|
||||||
|
CREATE INDEX template_suppressions_app_idx ON template_suppressions (app_id);
|
||||||
19
crates/manager-core/migrations/0059_sealed_templates.sql
Normal file
19
crates/manager-core/migrations/0059_sealed_templates.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- §11 tail (v1.2 Hierarchies): `sealed` (mandatory) group templates.
|
||||||
|
--
|
||||||
|
-- Per-app opt-out (0058_template_suppressions) made an inherited group TRIGGER
|
||||||
|
-- or ROUTE template advisory-by-default: it runs on a descendant *unless* that
|
||||||
|
-- descendant declares a `[suppress]` declining it. That is a compliance footgun
|
||||||
|
-- — a group's audit / security / rate-limit template can be silently opted out
|
||||||
|
-- of by the very tenant it is meant to police.
|
||||||
|
--
|
||||||
|
-- A `sealed` template closes the gap: the two suppression filters skip a sealed
|
||||||
|
-- row, so it fires / serves on every descendant regardless of any suppression.
|
||||||
|
-- The column is only meaningful on a group-owned template (`group_id IS NOT
|
||||||
|
-- NULL`); an app-owned row is never inherited, so sealing it is meaningless and
|
||||||
|
-- rejected at apply. Existing rows default to unsealed (the prior behaviour).
|
||||||
|
--
|
||||||
|
-- * triggers.sealed — a sealed group trigger template is never suppressible.
|
||||||
|
-- * routes.sealed — a sealed group route template is never suppressible.
|
||||||
|
|
||||||
|
ALTER TABLE triggers ADD COLUMN sealed BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
ALTER TABLE routes ADD COLUMN sealed BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
38
crates/manager-core/migrations/0060_group_suppressions.sql
Normal file
38
crates/manager-core/migrations/0060_group_suppressions.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
-- §11 tail (v1.2 Hierarchies): GROUP-level template suppression.
|
||||||
|
--
|
||||||
|
-- 0058 let an APP decline an inherited group template (for that app only). A
|
||||||
|
-- group operator often wants the same opt-out for a whole subtree: "no
|
||||||
|
-- descendant of this group runs the ancestor's `audit` template." Until now
|
||||||
|
-- only an app could suppress; a group could not.
|
||||||
|
--
|
||||||
|
-- Reshape `template_suppressions` to a POLYMORPHIC owner, exactly like the
|
||||||
|
-- group triggers/routes reshape (0056/0057) and the config markers (0051/0052):
|
||||||
|
-- * `group_id` — nullable FK→groups, ON DELETE CASCADE (config, not code).
|
||||||
|
-- * `app_id` — made nullable; the exactly-one CHECK enforces app XOR group.
|
||||||
|
-- * the per-app unique index becomes per-owner partials.
|
||||||
|
--
|
||||||
|
-- A GROUP suppression declines a template the group INHERITS from a higher
|
||||||
|
-- ancestor, for the group's whole subtree. Inheritance-only still holds: the
|
||||||
|
-- dispatch filters gate to group-owned rows, and a suppression only matches on
|
||||||
|
-- the suppressing owner's ancestor chain — a group can decline what it
|
||||||
|
-- inherits, never a sibling subtree's, never its own descendants' own rows.
|
||||||
|
|
||||||
|
ALTER TABLE template_suppressions
|
||||||
|
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE template_suppressions
|
||||||
|
ALTER COLUMN app_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE template_suppressions
|
||||||
|
ADD CONSTRAINT template_suppressions_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||||
|
|
||||||
|
DROP INDEX template_suppressions_uidx;
|
||||||
|
CREATE UNIQUE INDEX template_suppressions_app_uidx
|
||||||
|
ON template_suppressions (app_id, target_kind, reference) WHERE app_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX template_suppressions_group_uidx
|
||||||
|
ON template_suppressions (group_id, target_kind, reference) WHERE group_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- The trigger anti-join / route rebuild look up by both owners now.
|
||||||
|
CREATE INDEX template_suppressions_group_idx
|
||||||
|
ON template_suppressions (group_id) WHERE group_id IS NOT NULL;
|
||||||
21
crates/manager-core/migrations/0061_shared_triggers.sql
Normal file
21
crates/manager-core/migrations/0061_shared_triggers.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- §11.6 / §4.5: SHARED-collection triggers.
|
||||||
|
--
|
||||||
|
-- A §11.6 shared collection is group-owned and written by any subtree app via
|
||||||
|
-- the `kv::shared_collection(...)` handles. Until now such writes fired NO
|
||||||
|
-- trigger (the "group trigger has no app to watch" deferral): the per-app
|
||||||
|
-- trigger dispatch keys on the writer app's OWN collections, and a shared
|
||||||
|
-- collection lives in a distinct group-keyed store.
|
||||||
|
--
|
||||||
|
-- A SHARED trigger closes that gap: a group-owned trigger template marked
|
||||||
|
-- `shared = true` watches the group's shared collection (not per-app
|
||||||
|
-- collections). On a shared-collection write the emitter matches these triggers
|
||||||
|
-- by the OWNING GROUP's chain and runs the handler under the WRITER app
|
||||||
|
-- (`cx.app_id`) — the same "group template runs under the firing app" model.
|
||||||
|
--
|
||||||
|
-- The `shared` marker is the namespace boundary: a `shared = false` trigger
|
||||||
|
-- (the default, incl. every existing row) watches per-app collections exactly
|
||||||
|
-- as before; a `shared = true` trigger watches shared collections only. The two
|
||||||
|
-- never cross (the per-app match queries add `AND t.shared = FALSE`, the shared
|
||||||
|
-- match queries `AND t.shared = TRUE`).
|
||||||
|
|
||||||
|
ALTER TABLE triggers ADD COLUMN shared BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- §4.5 M5: STATEFUL group trigger templates (cron / queue / email) via
|
||||||
|
-- per-descendant MATERIALIZATION.
|
||||||
|
--
|
||||||
|
-- The event kinds (kv/docs/files/pubsub) resolve a group template LIVE at
|
||||||
|
-- dispatch via the chain CTE — no per-app rows. The stateful kinds can't:
|
||||||
|
-- * cron needs a per-app `last_fired_at` the scheduler advances;
|
||||||
|
-- * queue needs the one-consumer-per-(app_id, queue_name) advisory lock;
|
||||||
|
-- * email needs a per-app SEALED inbound secret.
|
||||||
|
-- So a group stateful template is instead MATERIALIZED into an app-owned
|
||||||
|
-- trigger row per descendant app (created/removed as the tree changes), and the
|
||||||
|
-- unchanged dispatch paths (scheduler, queue consumer, email webhook) only ever
|
||||||
|
-- see app-owned rows.
|
||||||
|
--
|
||||||
|
-- `materialized_from` links a managed app-owned row back to the group template
|
||||||
|
-- it was expanded from: NULL = a hand-authored trigger; set = a reconciler-owned
|
||||||
|
-- copy (dropped + re-created as descendants come and go). ON DELETE CASCADE so
|
||||||
|
-- deleting the group template (RESTRICT-guarded elsewhere) or the group cleans
|
||||||
|
-- up the copies; a plain app delete CASCADEs the row away via `triggers.app_id`.
|
||||||
|
|
||||||
|
ALTER TABLE triggers
|
||||||
|
ADD COLUMN materialized_from UUID REFERENCES triggers(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- The reconciler looks up existing copies by their source template.
|
||||||
|
CREATE INDEX idx_triggers_materialized_from
|
||||||
|
ON triggers (materialized_from) WHERE materialized_from IS NOT NULL;
|
||||||
12
crates/manager-core/migrations/0063_materialized_unique.sql
Normal file
12
crates/manager-core/migrations/0063_materialized_unique.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- §4.5 M5: exactly one materialized copy per (descendant app, source template).
|
||||||
|
--
|
||||||
|
-- The materialization reconciler (materialize::rematerialize_stateful_templates)
|
||||||
|
-- runs on every tree/apply mutation, and two of them can run concurrently (e.g.
|
||||||
|
-- two apps created in parallel each trigger a full reconcile). Both compute the
|
||||||
|
-- same "missing" (app, template) pair and both try to insert the copy — without
|
||||||
|
-- a constraint that races into a DUPLICATE. This partial unique index makes the
|
||||||
|
-- second insert a no-op (the reconciler uses `ON CONFLICT DO NOTHING`), so a
|
||||||
|
-- copy is idempotent under concurrency.
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX triggers_materialized_uidx
|
||||||
|
ON triggers (app_id, materialized_from) WHERE materialized_from IS NOT NULL;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- §11.6 (cont., v1.2 Hierarchies): admit 'topic' and 'queue' as shared-
|
||||||
|
-- collection kinds.
|
||||||
|
--
|
||||||
|
-- D2 (shared topics) + D3 (shared queues) extend the group shared-collection
|
||||||
|
-- machinery from data stores (kv/docs/files) to the two event/messaging kinds.
|
||||||
|
-- A shared TOPIC is storeless — it is a group-scoped publish NAMESPACE watched
|
||||||
|
-- by `shared = true` group pubsub triggers (no per-kind storage table). A shared
|
||||||
|
-- QUEUE gets its group-keyed message store in 0065 (D3). This migration only
|
||||||
|
-- widens the marker allow-list; both kinds route through the same owner-generic
|
||||||
|
-- reconcile + `resolve_owning_group` path as the data kinds.
|
||||||
|
|
||||||
|
-- Widen the marker kind allow-list (auto-named `group_collections_kind_check`,
|
||||||
|
-- an inline column CHECK), matching 0054/0055.
|
||||||
|
ALTER TABLE group_collections
|
||||||
|
DROP CONSTRAINT group_collections_kind_check,
|
||||||
|
ADD CONSTRAINT group_collections_kind_check
|
||||||
|
CHECK (kind IN ('kv', 'docs', 'files', 'topic', 'queue'));
|
||||||
46
crates/manager-core/migrations/0065_group_queues.sql
Normal file
46
crates/manager-core/migrations/0065_group_queues.sql
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
-- §11.6 D3: group-shared durable queues.
|
||||||
|
--
|
||||||
|
-- The queue counterpart to the group_kv/docs/files shared stores (0053-0055).
|
||||||
|
-- A group declares a `queue` shared collection (kind admitted in 0064); any
|
||||||
|
-- subtree app enqueues into ONE group-owned store via
|
||||||
|
-- `queue::shared_collection(name).enqueue(...)`. Consumption is by COMPETING
|
||||||
|
-- CONSUMERS: a group `[[triggers.queue]] shared = true` template materializes a
|
||||||
|
-- consumer copy per descendant app (like the M5 stateful templates), and all
|
||||||
|
-- copies claim from this shared store with FOR UPDATE SKIP LOCKED — each message
|
||||||
|
-- delivered at-most-once across the whole subtree, scaling horizontally.
|
||||||
|
--
|
||||||
|
-- Identity tuple: (group_id, collection). Keyed by the OWNING GROUP, not an app
|
||||||
|
-- (the shared rows belong to the group). CASCADE on group delete (data dies with
|
||||||
|
-- its group; an app delete leaves it), matching the other group stores. Mirrors
|
||||||
|
-- queue_messages (0034) column-for-column, swapping (app_id, queue_name) for
|
||||||
|
-- (group_id, collection).
|
||||||
|
|
||||||
|
CREATE TABLE group_queue_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
collection TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deliver_after TIMESTAMPTZ NULL,
|
||||||
|
claim_token UUID NULL,
|
||||||
|
claimed_at TIMESTAMPTZ NULL,
|
||||||
|
attempt INT NOT NULL DEFAULT 0,
|
||||||
|
max_attempts INT NOT NULL DEFAULT 3,
|
||||||
|
enqueued_by_principal UUID NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Dispatch hot path: scan for unclaimed messages in (group_id, collection)
|
||||||
|
-- order by enqueued_at (the NOW() deliver_after filter is applied on the small
|
||||||
|
-- partial result, as in 0034).
|
||||||
|
CREATE INDEX idx_group_queue_messages_dispatch
|
||||||
|
ON group_queue_messages (group_id, collection, enqueued_at)
|
||||||
|
WHERE claim_token IS NULL;
|
||||||
|
|
||||||
|
-- depth() / depth_pending() helpers.
|
||||||
|
CREATE INDEX idx_group_queue_messages_group_collection
|
||||||
|
ON group_queue_messages (group_id, collection);
|
||||||
|
|
||||||
|
-- Reclaim-task scan: currently-leased messages past their visibility timeout.
|
||||||
|
CREATE INDEX idx_group_queue_messages_claimed
|
||||||
|
ON group_queue_messages (claimed_at)
|
||||||
|
WHERE claim_token IS NOT NULL;
|
||||||
41
crates/manager-core/migrations/0066_projects.sql
Normal file
41
crates/manager-core/migrations/0066_projects.sql
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
-- §7 multi-repo ownership: the `projects` registry + wiring the inert
|
||||||
|
-- `owner_project` seam.
|
||||||
|
--
|
||||||
|
-- A "project" is a repo-root that declaratively MANAGES a slice of the shared
|
||||||
|
-- group tree (contains its manifest as something it applies, not merely
|
||||||
|
-- references). §7's model is SINGLE-OWNER-PER-NODE: each group node is owned by
|
||||||
|
-- exactly one project; the first `pic apply` that declares a `[project]` claims
|
||||||
|
-- the node, and a second repo applying to an owned node is refused unless it
|
||||||
|
-- passes a group-admin-gated `--takeover`. Ownership ⟂ RBAC — it records which
|
||||||
|
-- manifest is authoritative, not whether a principal may act.
|
||||||
|
--
|
||||||
|
-- Identity is a committed `[project] slug` (stable across clones); the server
|
||||||
|
-- assigns the UUID (server-internal). The `owner_project UUID` column already
|
||||||
|
-- exists on `groups` (0047, inert) — this migration creates the table it was
|
||||||
|
-- always meant to reference and wires the FK. Apps carry NO owner column: an
|
||||||
|
-- app inherits ownership from its NEAREST claimed ancestor group (the ancestor
|
||||||
|
-- walk is the isolation boundary), faithful to §7's "don't co-own a node —
|
||||||
|
-- split config downward" corollary.
|
||||||
|
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- Instance-global identity, declared in the repo's committed root manifest
|
||||||
|
-- and frozen. First apply with a new slug registers the project.
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
-- The admin who first registered it. SET NULL (not CASCADE) — losing the
|
||||||
|
-- creator's account must not orphan-delete the project or un-claim its tree.
|
||||||
|
created_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Wire the inert 0047 seam. ON DELETE SET NULL un-claims the owned nodes (they
|
||||||
|
-- fall back to UI/API-owned) rather than cascading a destructive tree delete —
|
||||||
|
-- deleting a project must never destroy the groups/apps it happened to manage.
|
||||||
|
ALTER TABLE groups
|
||||||
|
ADD CONSTRAINT groups_owner_project_fk
|
||||||
|
FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Ownership lookups: "what does project X own?" (pic projects ls) and the
|
||||||
|
-- LEFT JOIN behind pic groups ls' owner column.
|
||||||
|
CREATE INDEX groups_owner_project_idx ON groups (owner_project);
|
||||||
25
crates/manager-core/migrations/0067_project_environments.sql
Normal file
25
crates/manager-core/migrations/0067_project_environments.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- §3 M3 (hermetic gate): persist the per-env approval policy server-side.
|
||||||
|
--
|
||||||
|
-- Before this table the policy lived ONLY in the apply request
|
||||||
|
-- (`ProjectDecl.environments`), so a hand-crafted request that omitted it was
|
||||||
|
-- ungated — the server had nothing authoritative to check against. This makes
|
||||||
|
-- the gate hermetic: the confirm-required set is stored per project and the
|
||||||
|
-- server enforces against `persisted ∪ declared` (an env is gated if EITHER the
|
||||||
|
-- stored row OR the request says so), so an omitted or flipped-to-false policy
|
||||||
|
-- can no longer bypass a gate a prior apply established.
|
||||||
|
--
|
||||||
|
-- One owner (the project), a single boolean policy per environment name — no
|
||||||
|
-- polymorphic owner and no environment_scope (unlike `vars`/`secrets`, which
|
||||||
|
-- are data the apply targets; this is metadata ABOUT the apply). CASCADE on the
|
||||||
|
-- project mirrors 0066's philosophy: un-claiming/deleting a project drops its
|
||||||
|
-- policy, it does not linger to gate a tree the project no longer owns.
|
||||||
|
|
||||||
|
CREATE TABLE project_environments (
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
-- The environment name as declared in `[project.environments]` (e.g.
|
||||||
|
-- "production"); matched against the apply's `--env`.
|
||||||
|
env_name TEXT NOT NULL,
|
||||||
|
-- TRUE => applying to this env is gated (needs `--approve <env>` + admin).
|
||||||
|
confirm BOOLEAN NOT NULL,
|
||||||
|
PRIMARY KEY (project_id, env_name)
|
||||||
|
);
|
||||||
43
crates/manager-core/migrations/0068_group_dead_letters.sql
Normal file
43
crates/manager-core/migrations/0068_group_dead_letters.sql
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
-- §11.6 D3 (dead-letter store for group SHARED durable queues).
|
||||||
|
--
|
||||||
|
-- Previously an exhausted shared-queue message was `drop_exhausted()`-ed with a
|
||||||
|
-- warning — silent data loss. Per-app queues persist to `dead_letters` (0010);
|
||||||
|
-- this is the symmetric group store, keyed by `group_id` (the shared-queue owner)
|
||||||
|
-- + `collection` (a group has many shared queues) instead of `app_id`.
|
||||||
|
--
|
||||||
|
-- CASCADE on the group mirrors `group_queue_messages` (0065): deleting the group
|
||||||
|
-- drops its dead-letters; an app delete leaves them (the data belongs to the
|
||||||
|
-- group, not the consuming app). No `app_id` — a shared-queue message is not
|
||||||
|
-- owned by whichever descendant happened to consume it.
|
||||||
|
--
|
||||||
|
-- Fan-out to a *shared* dead-letter TRIGGER is deferred (would need a new shared
|
||||||
|
-- dead-letter trigger kind); M2 stops the data loss + makes exhausted messages
|
||||||
|
-- operator-visible (GET /api/v1/admin/groups/{id}/dead-letters).
|
||||||
|
|
||||||
|
CREATE TABLE group_dead_letters (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
-- The shared-queue collection name the exhausted message came from.
|
||||||
|
collection TEXT NOT NULL,
|
||||||
|
-- The group_queue_messages.id row that exhausted retries (already deleted).
|
||||||
|
original_event_id UUID NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
op TEXT NOT NULL,
|
||||||
|
-- The materialized consumer copy's trigger/script (nullable, mirrors 0010).
|
||||||
|
trigger_id UUID,
|
||||||
|
script_id UUID,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
attempt_count INT NOT NULL,
|
||||||
|
first_attempt_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_attempt_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_error TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
resolved_at TIMESTAMPTZ,
|
||||||
|
resolution TEXT
|
||||||
|
CHECK (resolution IN
|
||||||
|
('replayed', 'ignored', 'handled_by_script', 'handler_failed'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Operator listing: newest-first per group (GET .../groups/{id}/dead-letters).
|
||||||
|
CREATE INDEX idx_group_dead_letters_group
|
||||||
|
ON group_dead_letters (group_id, created_at DESC);
|
||||||
16
crates/manager-core/migrations/0069_email_secret_version.sql
Normal file
16
crates/manager-core/migrations/0069_email_secret_version.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- §M5.5 / audit 2026-06-11 H-D1: AAD-bind the email-trigger inbound secret.
|
||||||
|
--
|
||||||
|
-- `email_trigger_details.inbound_secret_encrypted` was sealed v0 (no AAD, bound
|
||||||
|
-- only to the master key) because this table had no version column — so a
|
||||||
|
-- ciphertext could in principle be relocated between rows. The per-app `secrets`
|
||||||
|
-- table gained AAD versioning in 0042; this brings the same to email secrets.
|
||||||
|
--
|
||||||
|
-- The AAD binds to the SEALING OWNER (the group for a template, the app for a
|
||||||
|
-- standalone trigger) — deliberately NOT the per-row trigger_id, so a group
|
||||||
|
-- template's sealed bytes stay valid when the materializer copies them verbatim
|
||||||
|
-- to each descendant app (all share the group AAD). At open time the sealing
|
||||||
|
-- owner is recovered from `materialized_from -> template.group_id` (else the
|
||||||
|
-- app). v0 rows keep their exact pre-audit no-AAD read path (default 0).
|
||||||
|
|
||||||
|
ALTER TABLE email_trigger_details
|
||||||
|
ADD COLUMN inbound_secret_version SMALLINT NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Audit 2026-07-11 (LOW C1): admin sessions had only a SLIDING expiry, so a
|
||||||
|
-- continuously-used (or stolen-but-warm) admin token never self-expired. The
|
||||||
|
-- data-plane app-user sessions (0027) already carry an absolute hard cap; give
|
||||||
|
-- the higher-privilege admin session the same. `touch` clamps the sliding bump
|
||||||
|
-- at this value, and `lookup` filters it, so an admin session dies at the later
|
||||||
|
-- of its idle window or this absolute ceiling — whichever comes first.
|
||||||
|
ALTER TABLE admin_sessions ADD COLUMN absolute_expires_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Backfill live rows: cap them 30 days from creation (the new default absolute
|
||||||
|
-- TTL). A row already older than that will be swept on its next lookup.
|
||||||
|
UPDATE admin_sessions
|
||||||
|
SET absolute_expires_at = created_at + INTERVAL '30 days'
|
||||||
|
WHERE absolute_expires_at IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE admin_sessions ALTER COLUMN absolute_expires_at SET NOT NULL;
|
||||||
|
|
||||||
|
-- Backs the absolute-expiry filter on lookup / prune.
|
||||||
|
CREATE INDEX admin_sessions_absolute_expiry_idx
|
||||||
|
ON admin_sessions (absolute_expires_at);
|
||||||
120
crates/manager-core/migrations/0071_workflows.sql
Normal file
120
crates/manager-core/migrations/0071_workflows.sql
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
-- v1.2 Workflows (blueprint §9.1/§9.2): a durable DAG orchestration engine.
|
||||||
|
--
|
||||||
|
-- A `workflow` is a declarative graph of steps; each step invokes a function
|
||||||
|
-- (a script, by name, resolved in the app's scope) or a nested sub-workflow,
|
||||||
|
-- with `depends_on` edges, a per-step `when` condition, input mapping, and a
|
||||||
|
-- per-step retry + `on_error` policy. A `workflow::start(name, input)` (or the
|
||||||
|
-- admin run API) creates a RUN; a dedicated background orchestrator advances it
|
||||||
|
-- step-by-step, durably, surviving restarts.
|
||||||
|
--
|
||||||
|
-- Three tables:
|
||||||
|
-- * workflows — the definition (owner-polymorphic like scripts/0050:
|
||||||
|
-- app XOR group; M1 authors app-owned only, the group_id
|
||||||
|
-- column ships unused so group-owned templates stay a
|
||||||
|
-- door-open follow-up).
|
||||||
|
-- * workflow_runs — one row per start(); the durable run record.
|
||||||
|
-- * workflow_run_steps — one row per step per run; carries the competing-
|
||||||
|
-- consumer lease (claim_token/claimed_at) exactly like
|
||||||
|
-- queue_messages (0034), so parallel steps complete
|
||||||
|
-- lock-free and a crashed worker's step is reclaimable.
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- workflows: the definition (owner-polymorphic, mirrors scripts/0050).
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE workflows (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- exactly one of app_id / group_id (the exactly-one CHECK below). Code is
|
||||||
|
-- not data, so RESTRICT (a group can't be deleted out from under a workflow
|
||||||
|
-- it owns), mirroring scripts.
|
||||||
|
app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
group_id UUID NULL REFERENCES groups(id) ON DELETE RESTRICT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
-- the parsed DAG: { "steps": [ { name, function|workflow, input, depends_on,
|
||||||
|
-- when, retry, on_error } ] }. Validated server-side (acyclic, unique step
|
||||||
|
-- names, deps exist) before it is ever written.
|
||||||
|
definition JSONB NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT workflows_owner_exactly_one CHECK ((group_id IS NULL) <> (app_id IS NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Per-owner, case-insensitive name uniqueness (partial, one per owner column).
|
||||||
|
CREATE UNIQUE INDEX workflows_app_name_uidx
|
||||||
|
ON workflows (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX workflows_group_name_uidx
|
||||||
|
ON workflows (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE INDEX workflows_group_id_idx ON workflows (group_id) WHERE group_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- workflow_runs: one row per start(). Carries app_id NOT NULL (the data-plane
|
||||||
|
-- invariant — every run-listing query filters by app without a join) even
|
||||||
|
-- though it is derivable from the workflow.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE workflow_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE RESTRICT,
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'canceled')),
|
||||||
|
input JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
output JSONB NULL,
|
||||||
|
error TEXT NULL,
|
||||||
|
-- correlates every step execution in execution_logs under one id.
|
||||||
|
root_execution_id UUID NOT NULL,
|
||||||
|
-- nesting: a sub-workflow run links back to the parent step that spawned it.
|
||||||
|
workflow_depth INT NOT NULL DEFAULT 0,
|
||||||
|
parent_run_id UUID NULL REFERENCES workflow_runs(id) ON DELETE SET NULL,
|
||||||
|
parent_step_id UUID NULL,
|
||||||
|
started_at TIMESTAMPTZ NULL,
|
||||||
|
finished_at TIMESTAMPTZ NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX workflow_runs_app_status_idx ON workflow_runs (app_id, status);
|
||||||
|
CREATE INDEX workflow_runs_workflow_idx ON workflow_runs (workflow_id, created_at DESC);
|
||||||
|
CREATE INDEX workflow_runs_parent_idx ON workflow_runs (parent_run_id) WHERE parent_run_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- workflow_run_steps: one row per step per run. The claim_token/claimed_at
|
||||||
|
-- lease is the queue_messages (0034) competing-consumer pattern — the
|
||||||
|
-- orchestrator claims a `ready` step FOR UPDATE SKIP LOCKED, and the reclaim
|
||||||
|
-- task frees a stale lease so a crashed worker's step is retried.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE workflow_run_steps (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
run_id UUID NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
|
||||||
|
step_name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending', 'ready', 'running', 'succeeded', 'failed', 'skipped')),
|
||||||
|
attempt INT NOT NULL DEFAULT 0,
|
||||||
|
max_attempts INT NOT NULL DEFAULT 1,
|
||||||
|
output JSONB NULL,
|
||||||
|
error TEXT NULL,
|
||||||
|
-- competing-consumer lease (mirrors queue_messages).
|
||||||
|
claim_token UUID NULL,
|
||||||
|
claimed_at TIMESTAMPTZ NULL,
|
||||||
|
-- retry backoff / initial dispatch gate (NULL = due immediately once ready).
|
||||||
|
next_attempt_at TIMESTAMPTZ NULL,
|
||||||
|
-- set when this step is a nested sub-workflow: the child run it is waiting on.
|
||||||
|
child_run_id UUID NULL REFERENCES workflow_runs(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (run_id, step_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The orchestrator's claim scan: ready steps whose backoff gate has elapsed.
|
||||||
|
-- (deliver-gate NOW() comparison is applied as a filter on the small partial
|
||||||
|
-- set, as in queue_messages.)
|
||||||
|
CREATE INDEX workflow_run_steps_claimable_idx
|
||||||
|
ON workflow_run_steps (next_attempt_at)
|
||||||
|
WHERE status = 'ready';
|
||||||
|
|
||||||
|
-- Advance re-eval + reclaim scans by run.
|
||||||
|
CREATE INDEX workflow_run_steps_run_idx ON workflow_run_steps (run_id);
|
||||||
|
|
||||||
|
-- Reclaim-task scan: leased steps whose claim is older than the visibility
|
||||||
|
-- timeout (bounded by in-flight step count).
|
||||||
|
CREATE INDEX workflow_run_steps_claimed_idx
|
||||||
|
ON workflow_run_steps (claimed_at)
|
||||||
|
WHERE claim_token IS NOT NULL;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- v1.2 Workflows M2: the orchestrator executes each workflow step as a normal
|
||||||
|
-- function invocation and logs it, so `pic logs` can surface (and filter by)
|
||||||
|
-- workflow-driven runs — exactly as it does for cron/queue/invoke triggers.
|
||||||
|
--
|
||||||
|
-- Add 'workflow' to the execution_logs.source CHECK (extends 0043). The column
|
||||||
|
-- default stays 'http'; this only widens the allowed set. Keep this list in
|
||||||
|
-- sync with `shared::ExecutionSource` (which gains the matching `Workflow`
|
||||||
|
-- variant) and `manager-core::OutboxSourceKind`.
|
||||||
|
--
|
||||||
|
-- Postgres has no ALTER … ALTER CONSTRAINT for a CHECK, so drop + re-add.
|
||||||
|
|
||||||
|
ALTER TABLE execution_logs DROP CONSTRAINT execution_logs_source_check;
|
||||||
|
ALTER TABLE execution_logs
|
||||||
|
ADD CONSTRAINT execution_logs_source_check
|
||||||
|
CHECK (source IN (
|
||||||
|
'http', 'kv', 'docs', 'dead_letter', 'cron',
|
||||||
|
'files', 'pubsub', 'email', 'invoke', 'queue', 'workflow'
|
||||||
|
));
|
||||||
42
crates/manager-core/migrations/0073_interceptors.sql
Normal file
42
crates/manager-core/migrations/0073_interceptors.sql
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
-- §9.4 Service Interceptors (v1.2) — before-op allow/deny hooks.
|
||||||
|
--
|
||||||
|
-- A marker `(owner, service, op) -> interceptor_script` declares that a script
|
||||||
|
-- runs BEFORE a data-plane operation and may deny it. MVP scope: `service='kv'`,
|
||||||
|
-- `op IN ('set','delete')`, allow/deny only (no data transform, no chaining,
|
||||||
|
-- no after-hooks). The interceptor is itself a script owned by the same node
|
||||||
|
-- (or an ancestor group); it is resolved + run through the existing `invoke()`
|
||||||
|
-- re-entry path, so this table holds only the MARKER — pure declaration,
|
||||||
|
-- structurally like an `extension_points` row (0051): config, not code, so
|
||||||
|
-- ON DELETE CASCADE.
|
||||||
|
--
|
||||||
|
-- Ownership is polymorphic (mirrors extension_points/vars/secrets/scripts):
|
||||||
|
-- exactly one of (app_id, group_id) is set. Resolution walks the calling app's
|
||||||
|
-- chain (app, then nearest ancestor group) and picks the nearest declaration
|
||||||
|
-- for a (service, op) — nearest-owner-wins, so an app overrides a group's
|
||||||
|
-- interceptor, the deliberate inverse of a sealed import.
|
||||||
|
|
||||||
|
CREATE TABLE interceptors (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT interceptors_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||||
|
-- The intercepted operation. MVP: service='kv', op IN ('set','delete').
|
||||||
|
service TEXT NOT NULL,
|
||||||
|
op TEXT NOT NULL,
|
||||||
|
-- Name of the interceptor script (resolved on the owner's chain at run time).
|
||||||
|
interceptor_script TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One marker per (owner, service, op). Partial because the owner is split
|
||||||
|
-- across two nullable columns.
|
||||||
|
CREATE UNIQUE INDEX interceptors_group_uidx
|
||||||
|
ON interceptors (group_id, service, op) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX interceptors_app_uidx
|
||||||
|
ON interceptors (app_id, service, op) WHERE app_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Lookup indexes for the resolver's chain join + list-by-owner.
|
||||||
|
CREATE INDEX interceptors_group_id_idx ON interceptors (group_id) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE INDEX interceptors_app_id_idx ON interceptors (app_id) WHERE app_id IS NOT NULL;
|
||||||
20
crates/manager-core/migrations/0074_interceptor_phase.sql
Normal file
20
crates/manager-core/migrations/0074_interceptor_phase.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- §9.4 Service Interceptors M2 — before + after phases.
|
||||||
|
--
|
||||||
|
-- The 0073 marker guarded only the BEFORE-op allow/deny hook. M2 introduces an
|
||||||
|
-- explicit `phase` so an owner can declare a `before` AND an `after` interceptor
|
||||||
|
-- for one `(service, op)`. Existing markers default to `phase='before'` — the
|
||||||
|
-- 0073 behaviour is unchanged. The partial-unique indexes gain `phase` so the
|
||||||
|
-- two phases are distinct rows per owner (one before + one after each).
|
||||||
|
|
||||||
|
ALTER TABLE interceptors
|
||||||
|
ADD COLUMN phase TEXT NOT NULL DEFAULT 'before'
|
||||||
|
CHECK (phase IN ('before', 'after'));
|
||||||
|
|
||||||
|
-- Recreate the per-owner uniqueness to be per (owner, service, op, PHASE).
|
||||||
|
DROP INDEX interceptors_group_uidx;
|
||||||
|
DROP INDEX interceptors_app_uidx;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX interceptors_group_uidx
|
||||||
|
ON interceptors (group_id, service, op, phase) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX interceptors_app_uidx
|
||||||
|
ON interceptors (app_id, service, op, phase) WHERE app_id IS NOT NULL;
|
||||||
10
crates/manager-core/migrations/0075_interceptor_timeout.sql
Normal file
10
crates/manager-core/migrations/0075_interceptor_timeout.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- §9.4 Service Interceptors M5 — per-interceptor wall-clock timeout.
|
||||||
|
--
|
||||||
|
-- An optional per-marker `timeout_ms` bounds how long ONE interceptor hook may
|
||||||
|
-- run before it is denied (a runaway guard, e.g. `loop {}`, must not hang the
|
||||||
|
-- guarded write path). NULL means "use the instance default"
|
||||||
|
-- (`PICLOUD_INTERCEPTOR_TIMEOUT_MS`). The effective deadline is always tightened
|
||||||
|
-- to at most the caller's remaining budget — a hook can never EXTEND it.
|
||||||
|
ALTER TABLE interceptors
|
||||||
|
ADD COLUMN timeout_ms INTEGER
|
||||||
|
CHECK (timeout_ms IS NULL OR timeout_ms > 0);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- A3 raw SMTP ingress — an inbound email ADDRESS an email trigger listens on.
|
||||||
|
--
|
||||||
|
-- The HMAC-webhook path (v1.1.7) addresses a trigger by its UUID in the URL. A
|
||||||
|
-- native SMTP listener instead receives `RCPT TO:<address>` and must resolve
|
||||||
|
-- that address to the bound trigger. `inbound_address` is the mailbox the
|
||||||
|
-- trigger claims; NULL for a webhook-only trigger. Case-insensitively unique
|
||||||
|
-- among app-owned triggers so an address resolves to exactly one script.
|
||||||
|
ALTER TABLE email_trigger_details
|
||||||
|
ADD COLUMN inbound_address TEXT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX email_trigger_details_inbound_address_uidx
|
||||||
|
ON email_trigger_details (lower(inbound_address))
|
||||||
|
WHERE inbound_address IS NOT NULL;
|
||||||
11
crates/manager-core/migrations/0077_apps_cors.sql
Normal file
11
crates/manager-core/migrations/0077_apps_cors.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- F-030 per-app CORS. A decoupled browser SPA on a different origin cannot
|
||||||
|
-- call an app's user routes today: the orchestrator emits no
|
||||||
|
-- `Access-Control-Allow-Origin` header and never answers an OPTIONS preflight.
|
||||||
|
--
|
||||||
|
-- This adds a per-app allow-list of origins. The orchestrator (which has no DB)
|
||||||
|
-- reads it into its in-memory cache on each domain-cache rebuild, echoes the
|
||||||
|
-- request `Origin` when it matches, and short-circuits preflights. Empty array
|
||||||
|
-- (the default) = CORS off, preserving today's behaviour. `["*"]` allows any
|
||||||
|
-- origin.
|
||||||
|
ALTER TABLE apps
|
||||||
|
ADD COLUMN cors_allowed_origins JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||||
@@ -17,13 +17,15 @@ pub enum AdminSessionRepositoryError {
|
|||||||
Db(#[from] sqlx::Error),
|
Db(#[from] sqlx::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of a session lookup. Includes the user id (for auth context)
|
/// Result of a session lookup. Includes the user id (for auth context),
|
||||||
/// and the existing `expires_at` so the middleware can decide whether
|
/// the existing `expires_at` (so the middleware can decide whether the
|
||||||
/// the sliding window bump is worth a write.
|
/// sliding-window bump is worth a write), and the `absolute_expires_at`
|
||||||
|
/// hard cap the bump must clamp at (C1).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AdminSessionLookup {
|
pub struct AdminSessionLookup {
|
||||||
pub user_id: AdminUserId,
|
pub user_id: AdminUserId,
|
||||||
pub expires_at: DateTime<Utc>,
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub absolute_expires_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -33,9 +35,11 @@ pub trait AdminSessionRepository: Send + Sync {
|
|||||||
user_id: AdminUserId,
|
user_id: AdminUserId,
|
||||||
token_hash: &str,
|
token_hash: &str,
|
||||||
expires_at: DateTime<Utc>,
|
expires_at: DateTime<Utc>,
|
||||||
|
absolute_expires_at: DateTime<Utc>,
|
||||||
) -> Result<(), AdminSessionRepositoryError>;
|
) -> Result<(), AdminSessionRepositoryError>;
|
||||||
/// Look up a session by token hash. Returns `None` for missing or
|
/// Look up a session by token hash. Returns `None` for missing or
|
||||||
/// already-expired rows (the query filters them).
|
/// already-expired rows — either the sliding `expires_at` OR the
|
||||||
|
/// absolute cap having passed (the query filters both).
|
||||||
async fn lookup(
|
async fn lookup(
|
||||||
&self,
|
&self,
|
||||||
token_hash: &str,
|
token_hash: &str,
|
||||||
@@ -78,14 +82,16 @@ impl AdminSessionRepository for PostgresAdminSessionRepository {
|
|||||||
user_id: AdminUserId,
|
user_id: AdminUserId,
|
||||||
token_hash: &str,
|
token_hash: &str,
|
||||||
expires_at: DateTime<Utc>,
|
expires_at: DateTime<Utc>,
|
||||||
|
absolute_expires_at: DateTime<Utc>,
|
||||||
) -> Result<(), AdminSessionRepositoryError> {
|
) -> Result<(), AdminSessionRepositoryError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO admin_sessions (token_hash, user_id, expires_at) \
|
"INSERT INTO admin_sessions (token_hash, user_id, expires_at, absolute_expires_at) \
|
||||||
VALUES ($1, $2, $3)",
|
VALUES ($1, $2, $3, $4)",
|
||||||
)
|
)
|
||||||
.bind(token_hash)
|
.bind(token_hash)
|
||||||
.bind(user_id.into_inner())
|
.bind(user_id.into_inner())
|
||||||
.bind(expires_at)
|
.bind(expires_at)
|
||||||
|
.bind(absolute_expires_at)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -95,16 +101,17 @@ impl AdminSessionRepository for PostgresAdminSessionRepository {
|
|||||||
&self,
|
&self,
|
||||||
token_hash: &str,
|
token_hash: &str,
|
||||||
) -> Result<Option<AdminSessionLookup>, AdminSessionRepositoryError> {
|
) -> Result<Option<AdminSessionLookup>, AdminSessionRepositoryError> {
|
||||||
let row: Option<(uuid::Uuid, DateTime<Utc>)> = sqlx::query_as(
|
let row: Option<(uuid::Uuid, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
|
||||||
"SELECT user_id, expires_at FROM admin_sessions \
|
"SELECT user_id, expires_at, absolute_expires_at FROM admin_sessions \
|
||||||
WHERE token_hash = $1 AND expires_at > NOW()",
|
WHERE token_hash = $1 AND expires_at > NOW() AND absolute_expires_at > NOW()",
|
||||||
)
|
)
|
||||||
.bind(token_hash)
|
.bind(token_hash)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row.map(|(uid, exp)| AdminSessionLookup {
|
Ok(row.map(|(uid, exp, abs)| AdminSessionLookup {
|
||||||
user_id: uid.into(),
|
user_id: uid.into(),
|
||||||
expires_at: exp,
|
expires_at: exp,
|
||||||
|
absolute_expires_at: abs,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +151,10 @@ impl AdminSessionRepository for PostgresAdminSessionRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn prune_expired(&self) -> Result<u64, AdminSessionRepositoryError> {
|
async fn prune_expired(&self) -> Result<u64, AdminSessionRepositoryError> {
|
||||||
let res = sqlx::query("DELETE FROM admin_sessions WHERE expires_at <= NOW()")
|
let res = sqlx::query(
|
||||||
|
"DELETE FROM admin_sessions \
|
||||||
|
WHERE expires_at <= NOW() OR absolute_expires_at <= NOW()",
|
||||||
|
)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(res.rows_affected())
|
Ok(res.rows_affected())
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ use axum::{
|
|||||||
Extension, Json, Router,
|
Extension, Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptKind, ScriptSandbox,
|
AppId, ExecutionLog, ExecutionSource, GroupId, InstanceRole, Principal, Script, ScriptId,
|
||||||
ScriptValidator, ValidatedScript, ValidationError,
|
ScriptKind, ScriptOwner, ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
@@ -181,6 +181,27 @@ async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppI
|
|||||||
Ok(lookup.app.id)
|
Ok(lookup.app.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Capability authorizing an operation on a script of either owner (Phase 4).
|
||||||
|
/// App-owned scripts map to their `App*` capability exactly as before; a
|
||||||
|
/// group-owned script maps to the group-scoped capability, resolved against
|
||||||
|
/// the group ancestor walk. An orphan row (neither owner — impossible under
|
||||||
|
/// the DB CHECK) reads as a missing id.
|
||||||
|
///
|
||||||
|
/// The `app`/`group` arguments are the capability constructors for each
|
||||||
|
/// owner, so one helper serves read / write / admin / log-read by passing
|
||||||
|
/// the matching pair.
|
||||||
|
fn script_cap(
|
||||||
|
script: &Script,
|
||||||
|
app: fn(AppId) -> Capability,
|
||||||
|
group: fn(GroupId) -> Capability,
|
||||||
|
) -> Result<Capability, ApiError> {
|
||||||
|
match script.owner() {
|
||||||
|
Some(ScriptOwner::App(a)) => Ok(app(a)),
|
||||||
|
Some(ScriptOwner::Group(g)) => Ok(group(g)),
|
||||||
|
None => Err(ApiError::NotFound(script.id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
Extension(principal): Extension<Principal>,
|
Extension(principal): Extension<Principal>,
|
||||||
@@ -190,7 +211,7 @@ async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
require(
|
require(
|
||||||
state.authz.as_ref(),
|
state.authz.as_ref(),
|
||||||
&principal,
|
&principal,
|
||||||
Capability::AppRead(script.app_id),
|
script_cap(&script, Capability::AppRead, Capability::GroupScriptsRead)?,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(script))
|
Ok(Json(script))
|
||||||
@@ -234,7 +255,8 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
let created = state
|
let created = state
|
||||||
.repo
|
.repo
|
||||||
.create(NewScript {
|
.create(NewScript {
|
||||||
app_id: input.app_id,
|
app_id: Some(input.app_id),
|
||||||
|
group_id: None,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
@@ -246,6 +268,8 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
} else {
|
} else {
|
||||||
Some(input.sandbox)
|
Some(input.sandbox)
|
||||||
},
|
},
|
||||||
|
// Scripts are created active; toggling is a dedicated path.
|
||||||
|
enabled: true,
|
||||||
imports: validated.imports,
|
imports: validated.imports,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
@@ -258,7 +282,7 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
/// real KV bridge — defense against author confusion, not a security
|
/// real KV bridge — defense against author confusion, not a security
|
||||||
/// boundary (stdlib namespaces and module imports already live in
|
/// boundary (stdlib namespaces and module imports already live in
|
||||||
/// disjoint Rhai scopes).
|
/// disjoint Rhai scopes).
|
||||||
const RESERVED_MODULE_NAMES: &[&str] = &[
|
pub(crate) const RESERVED_MODULE_NAMES: &[&str] = &[
|
||||||
"log",
|
"log",
|
||||||
"regex",
|
"regex",
|
||||||
"random",
|
"random",
|
||||||
@@ -289,7 +313,11 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
require(
|
require(
|
||||||
state.authz.as_ref(),
|
state.authz.as_ref(),
|
||||||
&principal,
|
&principal,
|
||||||
Capability::AppWriteScript(script.app_id),
|
script_cap(
|
||||||
|
&script,
|
||||||
|
Capability::AppWriteScript,
|
||||||
|
Capability::GroupScriptsWrite,
|
||||||
|
)?,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -345,6 +373,7 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
memory_limit_mb: input.memory_limit_mb,
|
memory_limit_mb: input.memory_limit_mb,
|
||||||
sandbox: input.sandbox,
|
sandbox: input.sandbox,
|
||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
|
enabled: None,
|
||||||
imports: imports_for_patch,
|
imports: imports_for_patch,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -358,13 +387,15 @@ async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
// Delete is gated tighter than Save: editors can edit scripts but
|
// Delete is gated tighter than Save for app-owned scripts: editors can
|
||||||
// only app_admin / instance admin / owner can remove them. See
|
// edit but only app_admin / instance admin / owner can remove them (§11.6).
|
||||||
// blueprint §11.6.
|
// A group-owned script (Phase 4) has no group-admin-tier script cap, so it
|
||||||
|
// is gated at `GroupScriptsWrite` (editor+ on the group) — the same tier
|
||||||
|
// that created it; group deletion itself stays group_admin-gated elsewhere.
|
||||||
require(
|
require(
|
||||||
state.authz.as_ref(),
|
state.authz.as_ref(),
|
||||||
&principal,
|
&principal,
|
||||||
Capability::AppAdmin(script.app_id),
|
script_cap(&script, Capability::AppAdmin, Capability::GroupScriptsWrite)?,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
state.repo.delete(id).await?;
|
state.repo.delete(id).await?;
|
||||||
@@ -385,6 +416,10 @@ pub struct LogsQuery {
|
|||||||
#[serde(default, rename = "offset")]
|
#[serde(default, rename = "offset")]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub legacy_offset: Option<i64>,
|
pub legacy_offset: Option<i64>,
|
||||||
|
/// Optional origin filter (`http`, `kv`, `cron`, `invoke`, …). Absent
|
||||||
|
/// → all sources. An unrecognized value is a 422 (see `list_logs`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub source: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_limit() -> i64 {
|
const fn default_limit() -> i64 {
|
||||||
@@ -401,7 +436,11 @@ async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
require(
|
require(
|
||||||
state.authz.as_ref(),
|
state.authz.as_ref(),
|
||||||
&principal,
|
&principal,
|
||||||
Capability::AppLogRead(script.app_id),
|
script_cap(
|
||||||
|
&script,
|
||||||
|
Capability::AppLogRead,
|
||||||
|
Capability::GroupScriptsRead,
|
||||||
|
)?,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
// Cap to keep the dashboard responsive; the data plane writes are
|
// Cap to keep the dashboard responsive; the data plane writes are
|
||||||
@@ -411,7 +450,17 @@ async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
.cursor
|
.cursor
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(crate::repo::ExecutionLogCursor::decode);
|
.and_then(crate::repo::ExecutionLogCursor::decode);
|
||||||
let logs = state.logs.list_for_script(id, limit, cursor).await?;
|
let source = match q.source.as_deref() {
|
||||||
|
None | Some("" | "all") => None,
|
||||||
|
Some(s) => Some(
|
||||||
|
ExecutionSource::from_wire(s)
|
||||||
|
.ok_or_else(|| ApiError::BadRequest(format!("unknown log source: {s:?}")))?,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let logs = state
|
||||||
|
.logs
|
||||||
|
.list_for_script(id, limit, cursor, source)
|
||||||
|
.await?;
|
||||||
Ok(Json(logs))
|
Ok(Json(logs))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,6 +476,9 @@ pub enum ApiError {
|
|||||||
#[error("app not found: {0}")]
|
#[error("app not found: {0}")]
|
||||||
AppNotFound(String),
|
AppNotFound(String),
|
||||||
|
|
||||||
|
#[error("bad request: {0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
|
||||||
#[error("conflict: {0}")]
|
#[error("conflict: {0}")]
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
|
|
||||||
@@ -459,11 +511,10 @@ impl IntoResponse for ApiError {
|
|||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, message) = match &self {
|
let (status, message) = match &self {
|
||||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
|
Self::AppNotFound(_) | Self::BadRequest(_) | Self::Invalid(_) | Self::Ceiling(_) => {
|
||||||
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
|
||||||
Self::Invalid(_) | Self::Ceiling(_) => {
|
|
||||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||||
}
|
}
|
||||||
|
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||||
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||||
Self::AuthzRepo(e) => {
|
Self::AuthzRepo(e) => {
|
||||||
tracing::error!(error = %e, "authz repo error");
|
tracing::error!(error = %e, "authz repo error");
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::response::{IntoResponse, Json, Response};
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
use axum::routing::{delete, get};
|
use axum::routing::{delete, get};
|
||||||
use axum::{Extension, Router};
|
use axum::{Extension, Router};
|
||||||
@@ -116,7 +116,7 @@ async fn mint_key(
|
|||||||
State(state): State<ApiKeysState>,
|
State(state): State<ApiKeysState>,
|
||||||
Extension(principal): Extension<Principal>,
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<MintApiKeyRequest>,
|
Json(input): Json<MintApiKeyRequest>,
|
||||||
) -> Result<(StatusCode, Json<MintApiKeyResponse>), ApiKeysError> {
|
) -> Result<(StatusCode, HeaderMap, Json<MintApiKeyResponse>), ApiKeysError> {
|
||||||
validate_name(&input.name)?;
|
validate_name(&input.name)?;
|
||||||
validate_scopes(&input.scopes, input.app_id)?;
|
validate_scopes(&input.scopes, input.app_id)?;
|
||||||
|
|
||||||
@@ -135,6 +135,8 @@ async fn mint_key(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
|
// C2: the response carries the raw key exactly once — never cache it.
|
||||||
|
crate::auth_api::no_store_headers(),
|
||||||
Json(MintApiKeyResponse {
|
Json(MintApiKeyResponse {
|
||||||
key: row.into(),
|
key: row.into(),
|
||||||
raw_token: minted.raw,
|
raw_token: minted.raw,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use picloud_shared::{App, AppId, HostKind, PathKind};
|
use picloud_shared::{App, AppId, HostKind, PathKind, ScriptOwner};
|
||||||
|
|
||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
|
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
|
||||||
@@ -60,7 +60,8 @@ async fn seed_into(
|
|||||||
) -> Result<(), ScriptRepositoryError> {
|
) -> Result<(), ScriptRepositoryError> {
|
||||||
let script = scripts
|
let script = scripts
|
||||||
.create(NewScript {
|
.create(NewScript {
|
||||||
app_id: default.id,
|
app_id: Some(default.id),
|
||||||
|
group_id: None,
|
||||||
name: "hello".to_string(),
|
name: "hello".to_string(),
|
||||||
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
|
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
|
||||||
source: HELLO_RHAI_SOURCE.to_string(),
|
source: HELLO_RHAI_SOURCE.to_string(),
|
||||||
@@ -68,13 +69,14 @@ async fn seed_into(
|
|||||||
timeout_seconds: Some(5),
|
timeout_seconds: Some(5),
|
||||||
memory_limit_mb: None,
|
memory_limit_mb: None,
|
||||||
sandbox: None,
|
sandbox: None,
|
||||||
|
enabled: true,
|
||||||
imports: Vec::new(),
|
imports: Vec::new(),
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
routes
|
routes
|
||||||
.create(NewRoute {
|
.create(NewRoute {
|
||||||
app_id: default.id,
|
owner: ScriptOwner::App(default.id),
|
||||||
script_id: script.id,
|
script_id: script.id,
|
||||||
host_kind: HostKind::Any,
|
host_kind: HostKind::Any,
|
||||||
host: String::new(),
|
host: String::new(),
|
||||||
@@ -85,6 +87,8 @@ async fn seed_into(
|
|||||||
// `curl -d '{"name":"X"}' /hello` work out of the box.
|
// `curl -d '{"name":"X"}' /hello` work out of the box.
|
||||||
method: None,
|
method: None,
|
||||||
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
||||||
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,17 @@
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
|
use picloud_shared::{AdminUserId, AppId, AppRole, GroupId, InstanceRole, UserId};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::authz::{AuthzError, AuthzRepo};
|
use crate::authz::{AuthzError, AuthzRepo};
|
||||||
|
|
||||||
|
/// SQL fragment ranking the three role literals by authority so a CTE can
|
||||||
|
/// `MAX` over a mixed set of app_members + group_members rows. Shared by
|
||||||
|
/// both effective-role queries.
|
||||||
|
const ROLE_RANK_CTE: &str =
|
||||||
|
"role_rank(role, rank) AS (VALUES ('viewer', 1), ('editor', 2), ('app_admin', 3))";
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum AppMembersRepositoryError {
|
pub enum AppMembersRepositoryError {
|
||||||
#[error("database error: {0}")]
|
#[error("database error: {0}")]
|
||||||
@@ -281,13 +287,95 @@ impl AppMembersRepository for PostgresAppMembersRepository {
|
|||||||
impl AuthzRepo for PostgresAppMembersRepository {
|
impl AuthzRepo for PostgresAppMembersRepository {
|
||||||
async fn membership(
|
async fn membership(
|
||||||
&self,
|
&self,
|
||||||
user_id: AdminUserId,
|
user_id: UserId,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
) -> Result<Option<AppRole>, AuthzError> {
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
self.find(user_id, app_id)
|
self.find(user_id, app_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AuthzError::Repo(e.to_string()))
|
.map_err(|e| AuthzError::Repo(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Highest effective role on `app_id`: one recursive CTE walks the
|
||||||
|
/// app's group chain (app.group_id → groups.parent_id → … → root,
|
||||||
|
/// depth-bounded), then `MAX`es the app's own `app_members` row with
|
||||||
|
/// every ancestor `group_members` row by authority rank. A single
|
||||||
|
/// round-trip regardless of tree depth. `None` = no grant anywhere.
|
||||||
|
async fn effective_app_role(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
let row: Option<(String,)> = sqlx::query_as(&format!(
|
||||||
|
"WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT a.group_id AS gid, 0 AS depth FROM apps a WHERE a.id = $2
|
||||||
|
UNION ALL
|
||||||
|
SELECT g.parent_id, anc.depth + 1
|
||||||
|
FROM groups g JOIN ancestors anc ON g.id = anc.gid
|
||||||
|
WHERE g.parent_id IS NOT NULL AND anc.depth < 64
|
||||||
|
),
|
||||||
|
{ROLE_RANK_CTE}
|
||||||
|
SELECT eff.role
|
||||||
|
FROM (
|
||||||
|
SELECT am.role FROM app_members am
|
||||||
|
WHERE am.user_id = $1 AND am.app_id = $2
|
||||||
|
UNION ALL
|
||||||
|
SELECT gm.role FROM group_members gm
|
||||||
|
JOIN ancestors anc ON anc.gid = gm.group_id
|
||||||
|
WHERE gm.user_id = $1
|
||||||
|
) eff
|
||||||
|
JOIN role_rank rr ON rr.role = eff.role
|
||||||
|
ORDER BY rr.rank DESC
|
||||||
|
LIMIT 1"
|
||||||
|
))
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthzError::Repo(e.to_string()))?;
|
||||||
|
row.map(|(role,)| {
|
||||||
|
AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
|
||||||
|
"invalid role {role:?} in members table"
|
||||||
|
)))
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highest effective role on a *group* node — walks the group's own
|
||||||
|
/// ancestor chain over `group_members`. Gates group management.
|
||||||
|
async fn effective_group_role(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
group_id: GroupId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
let row: Option<(String,)> = sqlx::query_as(&format!(
|
||||||
|
"WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id AS gid, parent_id, 0 AS depth FROM groups WHERE id = $2
|
||||||
|
UNION ALL
|
||||||
|
SELECT g.id, g.parent_id, anc.depth + 1
|
||||||
|
FROM groups g JOIN ancestors anc ON g.id = anc.parent_id
|
||||||
|
WHERE anc.depth < 64
|
||||||
|
),
|
||||||
|
{ROLE_RANK_CTE}
|
||||||
|
SELECT gm.role
|
||||||
|
FROM group_members gm
|
||||||
|
JOIN ancestors anc ON anc.gid = gm.group_id
|
||||||
|
JOIN role_rank rr ON rr.role = gm.role
|
||||||
|
WHERE gm.user_id = $1
|
||||||
|
ORDER BY rr.rank DESC
|
||||||
|
LIMIT 1"
|
||||||
|
))
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(group_id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthzError::Repo(e.to_string()))?;
|
||||||
|
row.map(|(role,)| {
|
||||||
|
AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
|
||||||
|
"invalid role {role:?} in group_members table"
|
||||||
|
)))
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! that writes the history row in the same transaction.
|
//! that writes the history row in the same transaction.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{AdminUserId, App, AppId};
|
use picloud_shared::{AdminUserId, App, AppId, GroupId};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -55,6 +55,8 @@ pub trait AppRepository: Send + Sync {
|
|||||||
/// Only apps the user has an `app_members` row for. Drives the
|
/// Only apps the user has an `app_members` row for. Drives the
|
||||||
/// membership-filtered `GET /admin/apps` for `member` callers.
|
/// membership-filtered `GET /admin/apps` for `member` callers.
|
||||||
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError>;
|
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
|
/// Apps whose parent is `group_id`. Drives the group detail view.
|
||||||
|
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
async fn get_by_slug_or_history(
|
async fn get_by_slug_or_history(
|
||||||
@@ -67,6 +69,7 @@ pub trait AppRepository: Send + Sync {
|
|||||||
slug: &str,
|
slug: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
|
group_id: GroupId,
|
||||||
) -> Result<App, ScriptRepositoryError>;
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
/// Create that also consumes a matching `app_slug_history` row, if
|
/// Create that also consumes a matching `app_slug_history` row, if
|
||||||
/// any. Used after the operator has confirmed they want to break old
|
/// any. Used after the operator has confirmed they want to break old
|
||||||
@@ -76,6 +79,7 @@ pub trait AppRepository: Send + Sync {
|
|||||||
slug: &str,
|
slug: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
|
group_id: GroupId,
|
||||||
) -> Result<App, ScriptRepositoryError>;
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
async fn update(
|
async fn update(
|
||||||
&self,
|
&self,
|
||||||
@@ -99,6 +103,25 @@ pub trait AppRepository: Send + Sync {
|
|||||||
/// single transaction so a partial delete cannot be observed.
|
/// single transaction so a partial delete cannot be observed.
|
||||||
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
|
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
|
||||||
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>;
|
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>;
|
||||||
|
|
||||||
|
/// F-030 per-app CORS. The allow-list of origins each app permits on its
|
||||||
|
/// user routes, keyed by app_id — read once per domain-cache rebuild to
|
||||||
|
/// push into the orchestrator (which has no DB). Default: none (CORS off).
|
||||||
|
async fn cors_all(&self) -> Result<Vec<(AppId, Vec<String>)>, ScriptRepositoryError> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
/// The CORS allow-list for one app (`[]` = CORS disabled).
|
||||||
|
async fn get_cors(&self, _id: AppId) -> Result<Vec<String>, ScriptRepositoryError> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
/// Replace an app's CORS allow-list. `["*"]` allows any origin.
|
||||||
|
async fn set_cors(
|
||||||
|
&self,
|
||||||
|
_id: AppId,
|
||||||
|
_origins: Vec<String>,
|
||||||
|
) -> Result<(), ScriptRepositoryError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PostgresAppRepository {
|
pub struct PostgresAppRepository {
|
||||||
@@ -116,7 +139,7 @@ impl PostgresAppRepository {
|
|||||||
impl AppRepository for PostgresAppRepository {
|
impl AppRepository for PostgresAppRepository {
|
||||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, AppRow>(
|
let rows = sqlx::query_as::<_, AppRow>(
|
||||||
"SELECT id, slug, name, description, created_at, updated_at \
|
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||||
FROM apps ORDER BY name",
|
FROM apps ORDER BY name",
|
||||||
)
|
)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
@@ -126,7 +149,7 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
|
|
||||||
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, AppRow>(
|
let rows = sqlx::query_as::<_, AppRow>(
|
||||||
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
"SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
|
||||||
FROM apps a \
|
FROM apps a \
|
||||||
JOIN app_members m ON m.app_id = a.id \
|
JOIN app_members m ON m.app_id = a.id \
|
||||||
WHERE m.user_id = $1 \
|
WHERE m.user_id = $1 \
|
||||||
@@ -138,9 +161,20 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||||
|
FROM apps WHERE group_id = $1 ORDER BY name",
|
||||||
|
)
|
||||||
|
.bind(group_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, AppRow>(
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
"SELECT id, slug, name, description, created_at, updated_at \
|
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||||
FROM apps WHERE id = $1",
|
FROM apps WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
@@ -151,7 +185,7 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
|
|
||||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, AppRow>(
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
"SELECT id, slug, name, description, created_at, updated_at \
|
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||||
FROM apps WHERE slug = $1",
|
FROM apps WHERE slug = $1",
|
||||||
)
|
)
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
@@ -181,7 +215,7 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
|
|
||||||
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, AppRow>(
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
"SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
|
||||||
FROM app_slug_history h \
|
FROM app_slug_history h \
|
||||||
JOIN apps a ON a.id = h.current_app_id \
|
JOIN apps a ON a.id = h.current_app_id \
|
||||||
WHERE h.slug = $1",
|
WHERE h.slug = $1",
|
||||||
@@ -197,15 +231,17 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
slug: &str,
|
slug: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
|
group_id: GroupId,
|
||||||
) -> Result<App, ScriptRepositoryError> {
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
let res = sqlx::query_as::<_, AppRow>(
|
let res = sqlx::query_as::<_, AppRow>(
|
||||||
"INSERT INTO apps (slug, name, description) \
|
"INSERT INTO apps (slug, name, description, group_id) \
|
||||||
VALUES ($1, $2, $3) \
|
VALUES ($1, $2, $3, $4) \
|
||||||
RETURNING id, slug, name, description, created_at, updated_at",
|
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(description)
|
.bind(description)
|
||||||
|
.bind(group_id.into_inner())
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -223,6 +259,7 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
slug: &str,
|
slug: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
|
group_id: GroupId,
|
||||||
) -> Result<App, ScriptRepositoryError> {
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
let mut tx = self.pool.begin().await?;
|
let mut tx = self.pool.begin().await?;
|
||||||
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||||
@@ -230,13 +267,14 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
let row = sqlx::query_as::<_, AppRow>(
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
"INSERT INTO apps (slug, name, description) \
|
"INSERT INTO apps (slug, name, description, group_id) \
|
||||||
VALUES ($1, $2, $3) \
|
VALUES ($1, $2, $3, $4) \
|
||||||
RETURNING id, slug, name, description, created_at, updated_at",
|
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(description)
|
.bind(description)
|
||||||
|
.bind(group_id.into_inner())
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await;
|
.await;
|
||||||
let row = match row {
|
let row = match row {
|
||||||
@@ -264,7 +302,7 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||||
updated_at = NOW() \
|
updated_at = NOW() \
|
||||||
WHERE id = $1 \
|
WHERE id = $1 \
|
||||||
RETURNING id, slug, name, description, created_at, updated_at",
|
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
.bind(name)
|
.bind(name)
|
||||||
@@ -298,7 +336,7 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
if current_slug == new_slug {
|
if current_slug == new_slug {
|
||||||
// No-op rename; just return the row.
|
// No-op rename; just return the row.
|
||||||
let row = sqlx::query_as::<_, AppRow>(
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
"SELECT id, slug, name, description, created_at, updated_at \
|
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||||
FROM apps WHERE id = $1",
|
FROM apps WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
@@ -357,7 +395,7 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
let row = sqlx::query_as::<_, AppRow>(
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
"UPDATE apps SET slug = $2, updated_at = NOW() \
|
"UPDATE apps SET slug = $2, updated_at = NOW() \
|
||||||
WHERE id = $1 \
|
WHERE id = $1 \
|
||||||
RETURNING id, slug, name, description, created_at, updated_at",
|
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
.bind(new_slug)
|
.bind(new_slug)
|
||||||
@@ -424,6 +462,32 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(count.0)
|
Ok(count.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn cors_all(&self) -> Result<Vec<(AppId, Vec<String>)>, ScriptRepositoryError> {
|
||||||
|
let rows: Vec<(uuid::Uuid, sqlx::types::Json<Vec<String>>)> =
|
||||||
|
sqlx::query_as("SELECT id, cors_allowed_origins FROM apps")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(|(id, j)| (id.into(), j.0)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_cors(&self, id: AppId) -> Result<Vec<String>, ScriptRepositoryError> {
|
||||||
|
let row: Option<(sqlx::types::Json<Vec<String>>,)> =
|
||||||
|
sqlx::query_as("SELECT cors_allowed_origins FROM apps WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|(j,)| j.0).unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_cors(&self, id: AppId, origins: Vec<String>) -> Result<(), ScriptRepositoryError> {
|
||||||
|
sqlx::query("UPDATE apps SET cors_allowed_origins = $2, updated_at = NOW() WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(sqlx::types::Json(origins))
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -432,6 +496,7 @@ struct AppRow {
|
|||||||
slug: String,
|
slug: String,
|
||||||
name: String,
|
name: String,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
|
group_id: uuid::Uuid,
|
||||||
created_at: chrono::DateTime<chrono::Utc>,
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
updated_at: chrono::DateTime<chrono::Utc>,
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
@@ -443,6 +508,7 @@ impl From<AppRow> for App {
|
|||||||
slug: r.slug,
|
slug: r.slug,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
|
group_id: r.group_id.into(),
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.updated_at,
|
||||||
}
|
}
|
||||||
|
|||||||
795
crates/manager-core/src/apply_api.rs
Normal file
795
crates/manager-core/src/apply_api.rs
Normal file
@@ -0,0 +1,795 @@
|
|||||||
|
//! Admin HTTP surface for the declarative reconcile engine.
|
||||||
|
//!
|
||||||
|
//! `POST /api/v1/admin/apps/{id}/plan` — diff a desired-state bundle
|
||||||
|
//! against the app's live state and return the plan. Read-only; requires
|
||||||
|
//! `AppRead`. The `apply` route (write path) lands in the next milestone.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
Extension, Json, Router,
|
||||||
|
};
|
||||||
|
use picloud_shared::{AppId, GroupId, Principal};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::apply_service::{
|
||||||
|
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
|
||||||
|
ExtensionPointInfo, InterceptorInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl,
|
||||||
|
RouteTemplateInfo, StructureMode, SuppressionInfo, TreeBundle, TreePlanResult,
|
||||||
|
TriggerTemplateInfo,
|
||||||
|
};
|
||||||
|
use crate::authz::{require, AuthzDenied, Capability};
|
||||||
|
use crate::group_repo::GroupRepository;
|
||||||
|
|
||||||
|
/// Build the apply/plan router. Mounted under `/api/v1/admin`.
|
||||||
|
pub fn apply_router(service: ApplyService) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/apps/{id}/plan", post(plan_handler))
|
||||||
|
.route("/apps/{id}/apply", post(apply_handler))
|
||||||
|
.route("/groups/{id}/plan", post(group_plan_handler))
|
||||||
|
.route("/groups/{id}/apply", post(group_apply_handler))
|
||||||
|
.route("/tree/plan", post(tree_plan_handler))
|
||||||
|
.route("/tree/apply", post(tree_apply_handler))
|
||||||
|
.route(
|
||||||
|
"/apps/{id}/extension-points",
|
||||||
|
get(app_extension_points_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/groups/{id}/extension-points",
|
||||||
|
get(group_extension_points_handler),
|
||||||
|
)
|
||||||
|
.route("/groups/{id}/collections", get(group_collections_handler))
|
||||||
|
.route("/groups/{id}/triggers", get(group_triggers_handler))
|
||||||
|
.route("/groups/{id}/routes", get(group_routes_handler))
|
||||||
|
.route("/apps/{id}/suppressions", get(app_suppressions_handler))
|
||||||
|
.route("/groups/{id}/suppressions", get(group_suppressions_handler))
|
||||||
|
.route("/apps/{id}/interceptors", get(app_interceptors_handler))
|
||||||
|
.route("/groups/{id}/interceptors", get(group_interceptors_handler))
|
||||||
|
.with_state(service)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only §9.4 interceptor report for an app: the RESOLVED chain view (every
|
||||||
|
/// marker guarding its writes, nearest-owner-wins). Viewer-tier `AppRead`. Backs
|
||||||
|
/// `pic interceptors ls --app`.
|
||||||
|
async fn app_interceptors_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<InterceptorInfo>>, ApplyError> {
|
||||||
|
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||||
|
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
let report = svc.interceptor_report(ApplyOwner::App(app_id)).await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only §9.4 interceptor report for a group: its OWN declared markers.
|
||||||
|
async fn group_interceptors_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<InterceptorInfo>>, ApplyError> {
|
||||||
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupScriptsRead(group_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
let report = svc.interceptor_report(ApplyOwner::Group(group_id)).await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only §11 tail suppression report for an app: the inherited templates it
|
||||||
|
/// declines (`target_kind`, `reference`). Viewer-tier `AppRead`. Backs
|
||||||
|
/// `pic suppress ls --app`.
|
||||||
|
async fn app_suppressions_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<SuppressionInfo>>, ApplyError> {
|
||||||
|
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||||
|
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
let report = svc.suppression_report(ApplyOwner::App(app_id)).await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only §11 tail M1 suppression report for a group: the inherited templates
|
||||||
|
/// it declines for its whole subtree (`target_kind`, `reference`). Viewer-tier
|
||||||
|
/// `GroupScriptsRead`. Backs `pic suppress ls --group`.
|
||||||
|
async fn group_suppressions_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<SuppressionInfo>>, ApplyError> {
|
||||||
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupScriptsRead(group_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
let report = svc.suppression_report(ApplyOwner::Group(group_id)).await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only §11 tail route-template report for a group: its own declared route
|
||||||
|
/// templates (method, host, path, handler script, dispatch, enabled). Viewer-
|
||||||
|
/// tier read. Backs `pic routes ls --group`.
|
||||||
|
async fn group_routes_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<RouteTemplateInfo>>, ApplyError> {
|
||||||
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupScriptsRead(group_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
let report = svc.route_report(ApplyOwner::Group(group_id)).await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only §11 tail trigger-template report for a group: its own declared
|
||||||
|
/// event trigger templates (kind, target, handler script, enabled). Viewer-tier
|
||||||
|
/// read. Backs `pic triggers ls --group`.
|
||||||
|
async fn group_triggers_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<TriggerTemplateInfo>>, ApplyError> {
|
||||||
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupScriptsRead(group_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
let report = svc.trigger_report(ApplyOwner::Group(group_id)).await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only §11.6 shared-collection report for a group: its own declared
|
||||||
|
/// shared KV collection names. Viewer-tier read.
|
||||||
|
async fn group_collections_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<CollectionInfo>>, ApplyError> {
|
||||||
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupScriptsRead(group_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
let report = svc.collection_report(ApplyOwner::Group(group_id)).await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only §5.5 extension-point report for an app: every EP visible on its
|
||||||
|
/// chain, each with `declared_here` + resolved `provider`. Viewer-tier read.
|
||||||
|
async fn app_extension_points_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<ExtensionPointInfo>>, ApplyError> {
|
||||||
|
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||||
|
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
let report = svc.extension_point_report(ApplyOwner::App(app_id)).await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only §5.5 extension-point report for a group: its own declared names.
|
||||||
|
async fn group_extension_points_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<ExtensionPointInfo>>, ApplyError> {
|
||||||
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupScriptsRead(group_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
let report = svc
|
||||||
|
.extension_point_report(ApplyOwner::Group(group_id))
|
||||||
|
.await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `plan` request (§7 M3): the desired bundle plus the optional declaring
|
||||||
|
/// `[project]`, so the plan can preview the ownership outcome + attach ceiling.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PlanRequest {
|
||||||
|
// Flattened so the bundle fields sit at the JSON top level — restoring the
|
||||||
|
// pre-§7 plan wire, which took a BARE `Json<Bundle>`. An older `pic` that
|
||||||
|
// POSTs a bare bundle still deserializes (`project` defaults to `None`), and
|
||||||
|
// a newer client sends the same bundle fields plus a top-level `project`
|
||||||
|
// key. (Apply is deliberately NOT flattened — its pre-§7 wire was already
|
||||||
|
// `{bundle, prune, …}`, so flattening it would instead break old apply
|
||||||
|
// clients; the two endpoints are asymmetric because their histories are.)
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub bundle: Bundle,
|
||||||
|
#[serde(default)]
|
||||||
|
pub project: Option<ProjectDecl>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ApplyRequest {
|
||||||
|
pub bundle: Bundle,
|
||||||
|
#[serde(default)]
|
||||||
|
pub prune: bool,
|
||||||
|
/// Optional bound-plan token from a prior `plan`. When present, apply
|
||||||
|
/// refuses (409) if the app's live state has changed since.
|
||||||
|
#[serde(default)]
|
||||||
|
pub expected_token: Option<String>,
|
||||||
|
/// §7 ownership: the declaring repo's `[project]` (absent = no claim). Also
|
||||||
|
/// carries the §3 M3 env approval policy (`project.environments`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub project: Option<ProjectDecl>,
|
||||||
|
/// §7 ownership: reassign a node owned by another project (group-admin).
|
||||||
|
#[serde(default)]
|
||||||
|
pub takeover: bool,
|
||||||
|
/// §3 M3: the environment this apply targets (the overlay merged). Drives the
|
||||||
|
/// per-env approval gate when the project marks it `confirm = true`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: Option<String>,
|
||||||
|
/// §3 M3: environments the actor explicitly approved (`--approve <env>`). A
|
||||||
|
/// confirm-required env is refused unless it appears here; `--yes` alone does
|
||||||
|
/// NOT add it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub approved_envs: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(req): Json<ApplyRequest>,
|
||||||
|
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||||
|
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||||
|
require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?;
|
||||||
|
// §3 M3: server-authoritative per-env approval gate. The governing policy is
|
||||||
|
// loaded from the project the SERVER resolves as owning this node (its
|
||||||
|
// nearest-claimed ancestor), unioned with the declared policy — so a request
|
||||||
|
// that omits or spoofs `[project]` can't bypass a gate the owning project
|
||||||
|
// established. If gated and approved, the override additionally requires
|
||||||
|
// ADMIN on the node (a step up from the editor write caps above) + is audited.
|
||||||
|
let policy = svc
|
||||||
|
.governing_env_policy(ApplyOwner::App(app_id), req.project.as_ref())
|
||||||
|
.await?;
|
||||||
|
if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? {
|
||||||
|
require(svc.authz.as_ref(), &principal, Capability::AppAdmin(app_id))
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
audit_gated_apply(&principal, req.env.as_deref(), 1);
|
||||||
|
}
|
||||||
|
let claim = OwnershipClaim {
|
||||||
|
project: req.project,
|
||||||
|
takeover: req.takeover,
|
||||||
|
principal: &principal,
|
||||||
|
};
|
||||||
|
let report = svc
|
||||||
|
.apply(
|
||||||
|
app_id,
|
||||||
|
&req.bundle,
|
||||||
|
req.prune,
|
||||||
|
&claim,
|
||||||
|
req.expected_token.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn plan_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(req): Json<PlanRequest>,
|
||||||
|
) -> Result<Json<PlanResult>, ApplyError> {
|
||||||
|
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||||
|
// NOTE: the returned `Plan` discloses live secret NAMES (not values). That
|
||||||
|
// is safe today only because `AppRead` and `AppSecretsRead` are co-granted
|
||||||
|
// at every tier (same `script:read` scope, both in the viewer role). If a
|
||||||
|
// future authz split puts `AppSecretsRead` on its own tier, this handler
|
||||||
|
// must additionally require it — otherwise it leaks names a principal
|
||||||
|
// couldn't enumerate via the secrets API.
|
||||||
|
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
let plan = svc.plan(app_id, &req.bundle, req.project.as_ref()).await?;
|
||||||
|
Ok(Json(plan))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Group node apply (Phase 5): a group declares only scripts + vars (+ secret
|
||||||
|
// names). The bundle reuses the app `Bundle` shape with empty routes/triggers;
|
||||||
|
// the service rejects routes/triggers on a group node.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn group_plan_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(req): Json<PlanRequest>,
|
||||||
|
) -> Result<Json<PlanResult>, ApplyError> {
|
||||||
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||||
|
// Plan discloses the group's script + var + secret names; viewer-tier reads.
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupScriptsRead(group_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
let plan = svc
|
||||||
|
.plan_owner(
|
||||||
|
ApplyOwner::Group(group_id),
|
||||||
|
&req.bundle,
|
||||||
|
req.project.as_ref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(plan))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn group_apply_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(req): Json<ApplyRequest>,
|
||||||
|
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||||
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||||
|
svc.require_group_node_writes(&principal, group_id, &req.bundle, req.prune)
|
||||||
|
.await?;
|
||||||
|
// §3 M3: per-env approval gate (governing ∪ declared, server-resolved owner)
|
||||||
|
// — an approved gated apply requires GroupAdmin.
|
||||||
|
let policy = svc
|
||||||
|
.governing_env_policy(ApplyOwner::Group(group_id), req.project.as_ref())
|
||||||
|
.await?;
|
||||||
|
if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? {
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupAdmin(group_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
audit_gated_apply(&principal, req.env.as_deref(), 1);
|
||||||
|
}
|
||||||
|
let claim = OwnershipClaim {
|
||||||
|
project: req.project,
|
||||||
|
takeover: req.takeover,
|
||||||
|
principal: &principal,
|
||||||
|
};
|
||||||
|
let report = svc
|
||||||
|
.apply_owner(
|
||||||
|
ApplyOwner::Group(group_id),
|
||||||
|
&req.bundle,
|
||||||
|
req.prune,
|
||||||
|
&claim,
|
||||||
|
req.expected_token.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Tree apply (Phase 5): a whole project subtree in one transaction. The actor
|
||||||
|
// must hold the relevant read/write caps for EVERY node touched.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TreeApplyRequest {
|
||||||
|
bundle: TreeBundle,
|
||||||
|
#[serde(default)]
|
||||||
|
prune: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
expected_token: Option<String>,
|
||||||
|
/// §7 ownership: one `[project]` for the whole tree (the root manifest's).
|
||||||
|
/// Also carries the §3 M3 env approval policy (`project.environments`).
|
||||||
|
#[serde(default)]
|
||||||
|
project: Option<ProjectDecl>,
|
||||||
|
#[serde(default)]
|
||||||
|
takeover: bool,
|
||||||
|
/// §6 M2: how to resolve a group whose server parent diverges from the
|
||||||
|
/// manifest (default `Refuse`; a pre-M2 CLI omits it).
|
||||||
|
#[serde(default)]
|
||||||
|
structure_mode: StructureMode,
|
||||||
|
/// §3 M3: the environment this apply targets (drives the per-env approval gate).
|
||||||
|
#[serde(default)]
|
||||||
|
env: Option<String>,
|
||||||
|
/// §3 M3: environments the actor explicitly approved (`--approve <env>`).
|
||||||
|
#[serde(default)]
|
||||||
|
approved_envs: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TreePlanRequest {
|
||||||
|
// Flattened for the same bare-bundle wire compatibility as `PlanRequest`.
|
||||||
|
#[serde(flatten)]
|
||||||
|
bundle: TreeBundle,
|
||||||
|
#[serde(default)]
|
||||||
|
project: Option<ProjectDecl>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn tree_plan_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Json(req): Json<TreePlanRequest>,
|
||||||
|
) -> Result<Json<TreePlanResult>, ApplyError> {
|
||||||
|
authz_tree(&svc, &principal, &req.bundle, None).await?;
|
||||||
|
Ok(Json(
|
||||||
|
svc.plan_tree(&req.bundle, req.project.as_ref()).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn tree_apply_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Json(req): Json<TreeApplyRequest>,
|
||||||
|
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||||
|
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
|
||||||
|
// §3 M3: server-authoritative per-env approval gate (governing ∪ declared).
|
||||||
|
// The governing side is the union of every declared node's owning-project
|
||||||
|
// policy, resolved server-side — omitting `[project]` can't dodge it. On an
|
||||||
|
// approved gated apply, require ADMIN on EVERY declared node (a step up from
|
||||||
|
// the editor write caps authz_tree checks) + audit.
|
||||||
|
let policy = svc
|
||||||
|
.governing_env_policy_tree(&req.bundle, req.project.as_ref())
|
||||||
|
.await?;
|
||||||
|
if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? {
|
||||||
|
require_admin_on_every_node(&svc, &principal, &req.bundle).await?;
|
||||||
|
audit_gated_apply(&principal, req.env.as_deref(), req.bundle.nodes.len());
|
||||||
|
}
|
||||||
|
let claim = OwnershipClaim {
|
||||||
|
project: req.project,
|
||||||
|
takeover: req.takeover,
|
||||||
|
principal: &principal,
|
||||||
|
};
|
||||||
|
let report = svc
|
||||||
|
.apply_tree(
|
||||||
|
&req.bundle,
|
||||||
|
req.prune,
|
||||||
|
&claim,
|
||||||
|
req.expected_token.as_deref(),
|
||||||
|
req.structure_mode,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §3 M3 (hermetic gate): the per-env approval gate, evaluated against the
|
||||||
|
/// EFFECTIVE policy (`governing ∪ declared`, built by
|
||||||
|
/// `ApplyService::governing_env_policy`) — the governing side is loaded from the
|
||||||
|
/// project the SERVER resolves as owning the node, so a request that omits or
|
||||||
|
/// weakens `[project]` can't bypass a gate the owning project established.
|
||||||
|
/// Returns `Ok(true)` when the target env is confirm-required
|
||||||
|
/// AND approved — the caller must then require admin + audit. `Ok(false)` when
|
||||||
|
/// not gated (no env, or `confirm = false` everywhere). `Err(ApprovalRequired)`
|
||||||
|
/// when gated and NOT in `approved_envs` (a blanket `--yes` never lands here).
|
||||||
|
fn env_gate_check(
|
||||||
|
policy: &std::collections::BTreeMap<String, bool>,
|
||||||
|
env: Option<&str>,
|
||||||
|
approved_envs: &[String],
|
||||||
|
) -> Result<bool, ApplyError> {
|
||||||
|
let Some(env) = env else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if !policy.get(env).copied().unwrap_or(false) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
if approved_envs.iter().any(|e| e == env) {
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Err(ApplyError::ApprovalRequired(env.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §3 M3: require `AppAdmin`/`GroupAdmin` on every declared node of a tree apply
|
||||||
|
/// (the "override a gate" authority, above editor write caps). Mirrors
|
||||||
|
/// `authz_tree`'s resolution: an app resolves UUID-or-slug (fail-closed); a group
|
||||||
|
/// node whose slug names no existing group is to-CREATE (its create is separately
|
||||||
|
/// admin-gated in the service), so it is skipped here rather than 404'd.
|
||||||
|
async fn require_admin_on_every_node(
|
||||||
|
svc: &ApplyService,
|
||||||
|
principal: &Principal,
|
||||||
|
bundle: &TreeBundle,
|
||||||
|
) -> Result<(), ApplyError> {
|
||||||
|
for node in &bundle.nodes {
|
||||||
|
match node.kind {
|
||||||
|
NodeKind::App => {
|
||||||
|
let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
|
||||||
|
require(svc.authz.as_ref(), principal, Capability::AppAdmin(app_id))
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
}
|
||||||
|
NodeKind::Group => {
|
||||||
|
if let Some(group) = svc
|
||||||
|
.groups
|
||||||
|
.get_by_slug(&node.slug)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
|
{
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
principal,
|
||||||
|
Capability::GroupAdmin(group.id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §3 M3: audit an approved gated-environment apply (actor + env + node count).
|
||||||
|
fn audit_gated_apply(principal: &Principal, env: Option<&str>, nodes: usize) {
|
||||||
|
tracing::info!(
|
||||||
|
user_id = ?principal.user_id,
|
||||||
|
env = ?env,
|
||||||
|
nodes,
|
||||||
|
"apply: approved gated-environment apply (audit)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-node capability check for a tree plan/apply. `prune` widens an apply's
|
||||||
|
/// write requirement to every kind. A plan (`prune` ignored, no writes) needs
|
||||||
|
/// only the per-node read cap.
|
||||||
|
async fn authz_tree(
|
||||||
|
svc: &ApplyService,
|
||||||
|
principal: &Principal,
|
||||||
|
bundle: &TreeBundle,
|
||||||
|
apply_prune: Option<bool>,
|
||||||
|
) -> Result<(), ApplyError> {
|
||||||
|
for node in &bundle.nodes {
|
||||||
|
match node.kind {
|
||||||
|
NodeKind::App => {
|
||||||
|
let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
|
||||||
|
match apply_prune {
|
||||||
|
Some(prune) => {
|
||||||
|
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NodeKind::Group => {
|
||||||
|
// §6: a group node that doesn't exist yet is to-CREATE — its
|
||||||
|
// authorization (InstanceCreateGroup / GroupAdmin(parent)) is
|
||||||
|
// enforced in the service's create path, so skip the
|
||||||
|
// existing-group capability check here rather than 404.
|
||||||
|
let Some(group) = svc
|
||||||
|
.groups
|
||||||
|
.get_by_slug(&node.slug)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let group_id = group.id;
|
||||||
|
match apply_prune {
|
||||||
|
Some(prune) => {
|
||||||
|
svc.require_group_node_writes(principal, group_id, &node.bundle, prune)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
principal,
|
||||||
|
Capability::GroupScriptsRead(group_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App-node write caps: per resource kind the bundle touches, widened to all
|
||||||
|
/// kinds when `prune` (which deletes empty-section resources + cascades). Read
|
||||||
|
/// is always needed. Shared by the single-app and tree apply paths.
|
||||||
|
async fn require_app_node_writes(
|
||||||
|
svc: &ApplyService,
|
||||||
|
principal: &Principal,
|
||||||
|
app_id: picloud_shared::AppId,
|
||||||
|
bundle: &Bundle,
|
||||||
|
prune: bool,
|
||||||
|
) -> Result<(), ApplyError> {
|
||||||
|
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
if prune || !bundle.scripts.is_empty() {
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
principal,
|
||||||
|
Capability::AppWriteScript(app_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
}
|
||||||
|
if prune || !bundle.routes.is_empty() {
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
principal,
|
||||||
|
Capability::AppWriteRoute(app_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
}
|
||||||
|
if prune || !bundle.triggers.is_empty() {
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
principal,
|
||||||
|
Capability::AppManageTriggers(app_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
}
|
||||||
|
if prune || !bundle.vars.is_empty() {
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
principal,
|
||||||
|
Capability::AppVarsWrite(app_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
}
|
||||||
|
// Email triggers resolve + decrypt a stored secret by name server-side
|
||||||
|
// (`AppSecretsRead`), so require it so apply can't bind a secret the
|
||||||
|
// principal couldn't otherwise read.
|
||||||
|
if bundle.triggers.iter().any(BundleTrigger::is_email) {
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
principal,
|
||||||
|
Capability::AppSecretsRead(app_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
|
||||||
|
async fn resolve_group_id(
|
||||||
|
groups: &dyn GroupRepository,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<GroupId, ApplyError> {
|
||||||
|
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||||
|
groups
|
||||||
|
.get_by_id(uuid.into())
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
|
} else {
|
||||||
|
groups
|
||||||
|
.get_by_slug(ident)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
|
};
|
||||||
|
found
|
||||||
|
.map(|g| g.id)
|
||||||
|
.ok_or_else(|| ApplyError::AppNotFound(format!("group {ident}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a slug-or-id path param to an `AppId`, mapping miss → 404.
|
||||||
|
/// Mirrors the `triggers_api` helper of the same shape.
|
||||||
|
async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApplyError> {
|
||||||
|
crate::app_repo::resolve_app(apps, ident)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
|
.map(|l| l.app.id)
|
||||||
|
// `pic apply` reconciles the group tree but never creates an app — make
|
||||||
|
// the miss actionable instead of a bare "app not found: cms" (F-006).
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ApplyError::AppNotFound(format!(
|
||||||
|
"{ident} — apply does not create apps; create it first with \
|
||||||
|
`pic apps create {ident}`, then re-run apply"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_authz(denied: AuthzDenied) -> ApplyError {
|
||||||
|
match denied {
|
||||||
|
AuthzDenied::Denied => ApplyError::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => ApplyError::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApplyError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, body) = match &self {
|
||||||
|
Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
|
||||||
|
Self::Invalid(_) | Self::OutsideAttachPoint(_) | Self::StructuralDivergence(_) => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
json!({ "error": self.to_string() }),
|
||||||
|
),
|
||||||
|
// 409 CONFLICT, all actionable: a stale bound plan (StateMoved), a
|
||||||
|
// §3 M3 gated env needing `--approve` (ApprovalRequired), or a §7
|
||||||
|
// ownership conflict (declare `[project]`, `--takeover`).
|
||||||
|
Self::StateMoved | Self::ApprovalRequired(_) | Self::OwnershipConflict(_) => {
|
||||||
|
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "apply authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Backend(e) => {
|
||||||
|
tracing::error!(error = %e, "apply backend error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// §7 wire compat: the plan endpoints `#[serde(flatten)]` the bundle, so a
|
||||||
|
// pre-§7 client that POSTs a BARE bundle (no `{bundle: ...}` wrapper, no
|
||||||
|
// `project`) must still deserialize — `project` defaults to `None`.
|
||||||
|
#[test]
|
||||||
|
fn plan_request_accepts_a_bare_bundle() {
|
||||||
|
let bare = serde_json::json!({
|
||||||
|
"scripts": [],
|
||||||
|
"secrets": ["TOKEN"],
|
||||||
|
});
|
||||||
|
let req: PlanRequest = serde_json::from_value(bare).expect("bare bundle deserializes");
|
||||||
|
assert!(req.project.is_none());
|
||||||
|
assert_eq!(req.bundle.secrets, vec!["TOKEN".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The newer wire shape — the same bundle fields at the top level plus a
|
||||||
|
// `project` key (what the current CLI sends) — also deserializes.
|
||||||
|
#[test]
|
||||||
|
fn plan_request_accepts_a_flattened_project() {
|
||||||
|
let with_proj = serde_json::json!({
|
||||||
|
"scripts": [],
|
||||||
|
"project": { "slug": "acme" },
|
||||||
|
});
|
||||||
|
let req: PlanRequest = serde_json::from_value(with_proj).expect("flattened project");
|
||||||
|
assert_eq!(req.project.expect("project").slug, "acme");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_plan_request_accepts_a_bare_bundle() {
|
||||||
|
let bare = serde_json::json!({ "nodes": [] });
|
||||||
|
let req: TreePlanRequest = serde_json::from_value(bare).expect("bare tree bundle");
|
||||||
|
assert!(req.project.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
7238
crates/manager-core/src/apply_service.rs
Normal file
7238
crates/manager-core/src/apply_service.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ use axum::http::StatusCode;
|
|||||||
use axum::response::{IntoResponse, Json, Response};
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
use axum::routing::{delete, get, post};
|
use axum::routing::{delete, get, post};
|
||||||
use axum::{Extension, Router};
|
use axum::{Extension, Router};
|
||||||
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain, RouteTable};
|
||||||
use picloud_shared::{App, AppDomain, AppId, AppRole, InstanceRole, Principal};
|
use picloud_shared::{App, AppDomain, AppId, AppRole, InstanceRole, Principal};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -25,6 +25,7 @@ use uuid::Uuid;
|
|||||||
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||||
|
use crate::group_repo::{GroupRepository, ROOT_GROUP_SLUG};
|
||||||
use crate::repo::ScriptRepositoryError;
|
use crate::repo::ScriptRepositoryError;
|
||||||
use crate::route_repo::RouteRepository;
|
use crate::route_repo::RouteRepository;
|
||||||
|
|
||||||
@@ -42,8 +43,19 @@ pub struct AppsState {
|
|||||||
/// Cached host → app_id lookup; replaced after every domain CRUD
|
/// Cached host → app_id lookup; replaced after every domain CRUD
|
||||||
/// operation so the orchestrator sees changes immediately.
|
/// operation so the orchestrator sees changes immediately.
|
||||||
pub domain_table: Arc<AppDomainTable>,
|
pub domain_table: Arc<AppDomainTable>,
|
||||||
|
/// §11 tail: the route match snapshot. Creating/deleting an app changes
|
||||||
|
/// which group route TEMPLATES are inherited, so it is rebuilt here
|
||||||
|
/// (full-live invalidation) — mirroring the domain_table refresh.
|
||||||
|
pub route_table: Arc<RouteTable>,
|
||||||
/// Capability gate — Phase 3.5.
|
/// Capability gate — Phase 3.5.
|
||||||
pub authz: Arc<dyn AuthzRepo>,
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
/// Group tree — resolves an app's parent group at create time
|
||||||
|
/// (defaults to the instance root).
|
||||||
|
pub groups: Arc<dyn GroupRepository>,
|
||||||
|
/// §4.5 M5: creating/deleting an app changes which ancestor-group stateful
|
||||||
|
/// templates it inherits, so its materialized cron/queue copies are
|
||||||
|
/// reconciled here (full-live, mirroring the route_table rebuild).
|
||||||
|
pub pool: sqlx::PgPool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apps_router(state: AppsState) -> Router {
|
pub fn apps_router(state: AppsState) -> Router {
|
||||||
@@ -62,6 +74,10 @@ pub fn apps_router(state: AppsState) -> Router {
|
|||||||
"/apps/{id_or_slug}/domains/{domain_id}",
|
"/apps/{id_or_slug}/domains/{domain_id}",
|
||||||
delete(delete_domain),
|
delete(delete_domain),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/cors",
|
||||||
|
get(get_cors_handler).put(set_cors_handler),
|
||||||
|
)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +96,10 @@ pub struct CreateAppRequest {
|
|||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
/// Parent group (slug or id). Defaults to the instance root group when
|
||||||
|
/// omitted — every app has a parent from day one (§9).
|
||||||
|
#[serde(default)]
|
||||||
|
pub group: Option<String>,
|
||||||
/// Set to `true` to consume an existing `app_slug_history` row for
|
/// Set to `true` to consume an existing `app_slug_history` row for
|
||||||
/// the requested slug (breaking old redirects).
|
/// the requested slug (breaking old redirects).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -118,6 +138,13 @@ pub struct SlugCheckResponse {
|
|||||||
pub reason: Option<String>,
|
pub reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F-030 per-app CORS config: the set of browser origins allowed to call this
|
||||||
|
/// app's user routes. `["*"]` = any origin. Empty = CORS off.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CorsConfig {
|
||||||
|
pub allowed_origins: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateDomainRequest {
|
pub struct CreateDomainRequest {
|
||||||
pub pattern: String,
|
pub pattern: String,
|
||||||
@@ -176,25 +203,51 @@ async fn create_app(
|
|||||||
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
|
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
|
||||||
validate_slug(&input.slug)?;
|
validate_slug(&input.slug)?;
|
||||||
|
|
||||||
|
// Resolve the parent group: an explicit `group` (slug or id) or the
|
||||||
|
// instance root by default. Placing an app under a specific group
|
||||||
|
// additionally requires group-write there.
|
||||||
|
let parent = resolve_group(&*s.groups, input.group.as_deref()).await?;
|
||||||
|
if input.group.is_some() {
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupWrite(parent.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
// Historical-slug check before insert: if the slug is in history
|
// Historical-slug check before insert: if the slug is in history
|
||||||
// and the caller hasn't asked to force takeover, surface a clean
|
// and the caller hasn't asked to force takeover, surface a clean
|
||||||
// 409 so the dashboard can present a "this will break old links"
|
// 409 so the dashboard can present a "this will break old links"
|
||||||
// confirmation.
|
// confirmation.
|
||||||
if !input.force_takeover {
|
if !input.force_takeover {
|
||||||
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
|
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
|
||||||
return Err(AppsApiError::SlugInHistory(current));
|
return Err(AppsApiError::SlugInHistory(Box::new(current)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let created = if input.force_takeover {
|
let created = if input.force_takeover {
|
||||||
s.apps
|
s.apps
|
||||||
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
|
.create_with_takeover(
|
||||||
|
&input.slug,
|
||||||
|
&input.name,
|
||||||
|
input.description.as_deref(),
|
||||||
|
parent.id,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
s.apps
|
s.apps
|
||||||
.create(&input.slug, &input.name, input.description.as_deref())
|
.create(
|
||||||
|
&input.slug,
|
||||||
|
&input.name,
|
||||||
|
input.description.as_deref(),
|
||||||
|
parent.id,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
|
// The new app may sit under a group that owns route TEMPLATES — make them
|
||||||
|
// servable immediately (full-live invalidation).
|
||||||
|
refresh_route_cache(&s).await;
|
||||||
Ok((StatusCode::CREATED, Json(created)))
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +288,31 @@ async fn compute_my_role(
|
|||||||
) -> Result<Option<AppRole>, AppsApiError> {
|
) -> Result<Option<AppRole>, AppsApiError> {
|
||||||
match principal.instance_role {
|
match principal.instance_role {
|
||||||
InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
|
InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
|
||||||
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
|
// Effective role: folds in inherited group memberships so the
|
||||||
|
// dashboard badge reflects what the caller can actually do.
|
||||||
|
InstanceRole::Member => Ok(authz.effective_app_role(principal.user_id, app_id).await?),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve an optional group identifier (slug or UUID) to a group,
|
||||||
|
/// defaulting to the instance root group when `None`.
|
||||||
|
async fn resolve_group(
|
||||||
|
groups: &dyn GroupRepository,
|
||||||
|
ident: Option<&str>,
|
||||||
|
) -> Result<picloud_shared::Group, AppsApiError> {
|
||||||
|
match ident {
|
||||||
|
None => groups
|
||||||
|
.get_by_slug(ROOT_GROUP_SLUG)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppsApiError::GroupNotFound(ROOT_GROUP_SLUG.to_string())),
|
||||||
|
Some(ident) => {
|
||||||
|
let found = if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||||
|
groups.get_by_id(uuid.into()).await?
|
||||||
|
} else {
|
||||||
|
groups.get_by_slug(ident).await?
|
||||||
|
};
|
||||||
|
found.ok_or_else(|| AppsApiError::GroupNotFound(ident.to_string()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +356,7 @@ async fn patch_app(
|
|||||||
Ok(app) => app,
|
Ok(app) => app,
|
||||||
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
|
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
|
||||||
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
|
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
|
||||||
return Err(AppsApiError::SlugInHistory(current));
|
return Err(AppsApiError::SlugInHistory(Box::new(current)));
|
||||||
}
|
}
|
||||||
return Err(AppsApiError::Conflict(msg));
|
return Err(AppsApiError::Conflict(msg));
|
||||||
}
|
}
|
||||||
@@ -313,6 +390,8 @@ async fn delete_app(
|
|||||||
s.apps.delete(app.id).await?;
|
s.apps.delete(app.id).await?;
|
||||||
}
|
}
|
||||||
refresh_domain_cache(&s).await?;
|
refresh_domain_cache(&s).await?;
|
||||||
|
// Drop the deleted app's slice (and any routes it owned) from the snapshot.
|
||||||
|
refresh_route_cache(&s).await;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,6 +524,44 @@ async fn delete_domain(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F-030: read an app's CORS allow-list. `AppRead` — same gate as viewing
|
||||||
|
/// domain claims (CORS is host-adjacent config).
|
||||||
|
async fn get_cors_handler(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<CorsConfig>, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppRead(app.id)).await?;
|
||||||
|
let allowed_origins = s.apps.get_cors(app.id).await?;
|
||||||
|
Ok(Json(CorsConfig { allowed_origins }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// F-030: replace an app's CORS allow-list, then refresh the domain cache so
|
||||||
|
/// the orchestrator serves the new policy immediately. `AppManageDomains` —
|
||||||
|
/// the same gate as domain-claim CRUD.
|
||||||
|
async fn set_cors_handler(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<CorsConfig>,
|
||||||
|
) -> Result<Json<CorsConfig>, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppManageDomains(app.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
s.apps
|
||||||
|
.set_cors(app.id, input.allowed_origins.clone())
|
||||||
|
.await?;
|
||||||
|
refresh_domain_cache(&s).await?;
|
||||||
|
Ok(Json(CorsConfig {
|
||||||
|
allowed_origins: input.allowed_origins,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -490,6 +607,31 @@ fn validate_slug(slug: &str) -> Result<(), AppsApiError> {
|
|||||||
|
|
||||||
/// Rebuild the in-memory host → app_id cache used by the orchestrator.
|
/// Rebuild the in-memory host → app_id cache used by the orchestrator.
|
||||||
/// Called after every domain CRUD operation.
|
/// Called after every domain CRUD operation.
|
||||||
|
/// §11 tail: rebuild the route match snapshot after a tree mutation that
|
||||||
|
/// changes route inheritance (an app gained/lost its place in the group tree,
|
||||||
|
/// so the set of ancestor-group route TEMPLATES it serves changed). Best-effort
|
||||||
|
/// to mirror the apply path — a failure leaves a stale-but-self-healing table
|
||||||
|
/// rather than failing the committed mutation.
|
||||||
|
async fn refresh_route_cache(state: &AppsState) {
|
||||||
|
if let Err(e) =
|
||||||
|
crate::route_admin::rebuild_route_table(state.routes.as_ref(), &state.route_table).await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "apps: route table refresh failed; it will self-heal");
|
||||||
|
}
|
||||||
|
// §4.5 M5: reconcile materialized stateful-template copies for the changed
|
||||||
|
// app set (best-effort; self-heals on the next mutation).
|
||||||
|
match crate::materialize::rematerialize_stateful_templates(&state.pool).await {
|
||||||
|
Ok(warnings) => {
|
||||||
|
for w in warnings {
|
||||||
|
tracing::warn!(warning = %w, "apps: stateful-template materialization warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "apps: stateful-template materialization failed; it will self-heal");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> {
|
pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> {
|
||||||
let all = state.domains.list_all().await?;
|
let all = state.domains.list_all().await?;
|
||||||
let compiled = all
|
let compiled = all
|
||||||
@@ -509,6 +651,10 @@ pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError>
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
state.domain_table.replace(compiled);
|
state.domain_table.replace(compiled);
|
||||||
|
// F-030: push the per-app CORS allow-lists into the same in-memory cache so
|
||||||
|
// the orchestrator can answer preflights + tag responses without a DB hit.
|
||||||
|
let cors: std::collections::HashMap<_, _> = state.apps.cors_all().await?.into_iter().collect();
|
||||||
|
state.domain_table.replace_cors(cors);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,14 +667,19 @@ pub enum AppsApiError {
|
|||||||
#[error("app not found: {0}")]
|
#[error("app not found: {0}")]
|
||||||
AppNotFound(String),
|
AppNotFound(String),
|
||||||
|
|
||||||
|
#[error("group not found: {0}")]
|
||||||
|
GroupNotFound(String),
|
||||||
|
|
||||||
#[error("domain not found: {0}")]
|
#[error("domain not found: {0}")]
|
||||||
DomainNotFound(Uuid),
|
DomainNotFound(Uuid),
|
||||||
|
|
||||||
#[error("invalid slug: {0}")]
|
#[error("invalid slug: {0}")]
|
||||||
InvalidSlug(String),
|
InvalidSlug(String),
|
||||||
|
|
||||||
|
// Boxed: `App` is large enough to trip clippy::result_large_err on
|
||||||
|
// every handler returning `Result<_, AppsApiError>`.
|
||||||
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
|
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
|
||||||
SlugInHistory(App),
|
SlugInHistory(Box<App>),
|
||||||
|
|
||||||
#[error("app still contains {0} script(s); delete or move them first")]
|
#[error("app still contains {0} script(s); delete or move them first")]
|
||||||
HasScripts(i64),
|
HasScripts(i64),
|
||||||
@@ -567,10 +718,22 @@ impl From<AuthzError> for AppsApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<crate::group_repo::GroupRepositoryError> for AppsApiError {
|
||||||
|
fn from(e: crate::group_repo::GroupRepositoryError) -> Self {
|
||||||
|
use crate::group_repo::GroupRepositoryError as G;
|
||||||
|
match e {
|
||||||
|
G::NotFound(id) => Self::GroupNotFound(id.to_string()),
|
||||||
|
G::Conflict(msg) => Self::Conflict(msg),
|
||||||
|
G::Db(e) => Self::Repo(ScriptRepositoryError::Db(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for AppsApiError {
|
impl IntoResponse for AppsApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, body) = match &self {
|
let (status, body) = match &self {
|
||||||
Self::AppNotFound(_)
|
Self::AppNotFound(_)
|
||||||
|
| Self::GroupNotFound(_)
|
||||||
| Self::DomainNotFound(_)
|
| Self::DomainNotFound(_)
|
||||||
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||||
|
|||||||
1999
crates/manager-core/src/atomic_write.rs
Normal file
1999
crates/manager-core/src/atomic_write.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -156,12 +156,19 @@ async fn login(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let token = generate_session_token();
|
let token = generate_session_token();
|
||||||
let expires_at = Utc::now()
|
let now = Utc::now();
|
||||||
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
// C1: an admin session dies at the EARLIER of its sliding window and its
|
||||||
|
// absolute cap. At create the sliding window is shorter, so `expires_at`
|
||||||
|
// starts as `now + ttl`; the absolute cap `now + absolute_ttl` is stored so
|
||||||
|
// the sliding `touch` can clamp to it as the session ages.
|
||||||
|
let expires_at =
|
||||||
|
now + ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
||||||
|
let absolute_expires_at = now
|
||||||
|
+ ChronoDuration::from_std(state.absolute_ttl).unwrap_or_else(|_| ChronoDuration::days(30));
|
||||||
|
|
||||||
if let Err(err) = state
|
if let Err(err) = state
|
||||||
.sessions
|
.sessions
|
||||||
.create(user_id, &token.hash, expires_at)
|
.create(user_id, &token.hash, expires_at, absolute_expires_at)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!(?err, "admin_sessions insert failed");
|
tracing::error!(?err, "admin_sessions insert failed");
|
||||||
@@ -174,6 +181,7 @@ async fn login(
|
|||||||
|
|
||||||
(
|
(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
|
no_store_headers(),
|
||||||
Json(LoginResponse {
|
Json(LoginResponse {
|
||||||
user: AdminUserDto {
|
user: AdminUserDto {
|
||||||
id: user_row.id,
|
id: user_row.id,
|
||||||
@@ -188,6 +196,15 @@ async fn login(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Cache-Control: no-store` for a response carrying a raw credential (a session
|
||||||
|
/// token, an API key). Prevents a browser / proxy / CDN cache from retaining the
|
||||||
|
/// secret where a later client could read it back. (C2)
|
||||||
|
pub(crate) fn no_store_headers() -> HeaderMap {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response {
|
async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response {
|
||||||
// Pull token without requiring a valid session (logout is idempotent).
|
// Pull token without requiring a valid session (logout is idempotent).
|
||||||
let token = extract_token_for_logout(&req);
|
let token = extract_token_for_logout(&req);
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ pub struct AuthState {
|
|||||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
pub keys: Arc<dyn ApiKeyRepository>,
|
pub keys: Arc<dyn ApiKeyRepository>,
|
||||||
pub ttl: Duration,
|
pub ttl: Duration,
|
||||||
|
/// C1: absolute hard cap on an admin session's lifetime. The sliding
|
||||||
|
/// `touch` bump is clamped at `session_start + absolute_ttl`, so even a
|
||||||
|
/// continuously-used token self-expires. Set at login via the session's
|
||||||
|
/// stored `absolute_expires_at`; this value seeds that at create time.
|
||||||
|
pub absolute_ttl: Duration,
|
||||||
/// F-P-009 — shared cache of resolved Principals. Constructed once
|
/// F-P-009 — shared cache of resolved Principals. Constructed once
|
||||||
/// at startup and cloned (same `Arc`) into every router state that
|
/// at startup and cloned (same `Arc`) into every router state that
|
||||||
/// resolves or revokes credentials, so a revocation-side eviction is
|
/// resolves or revokes credentials, so a revocation-side eviction is
|
||||||
@@ -279,8 +284,10 @@ async fn verify_session(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Sliding-window bump — inline so a DB blip surfaces as 500 rather
|
// Sliding-window bump — inline so a DB blip surfaces as 500 rather
|
||||||
// than silent stale sessions. Same shape as Phase 3a.
|
// than silent stale sessions. C1: clamp the bump at the session's absolute
|
||||||
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
|
// cap so a continuously-used token can't slide forever.
|
||||||
|
let sliding = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
|
||||||
|
let new_expires_at = sliding.min(lookup.absolute_expires_at);
|
||||||
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
|
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
|
||||||
tracing::error!(?err, "admin_sessions touch failed");
|
tracing::error!(?err, "admin_sessions touch failed");
|
||||||
return Err(InternalError);
|
return Err(InternalError);
|
||||||
@@ -304,7 +311,15 @@ async fn verify_api_key(state: &AuthState, rest: &str) -> Result<Option<Principa
|
|||||||
if rest.len() <= API_KEY_PREFIX_LEN {
|
if rest.len() <= API_KEY_PREFIX_LEN {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let prefix = &rest[..API_KEY_PREFIX_LEN];
|
// `rest` is the raw, attacker-controlled bearer value after `pic_` — it is
|
||||||
|
// NOT yet validated as the base32 body a real key has, so a byte-index slice
|
||||||
|
// (`&rest[..8]`) could split a multibyte UTF-8 codepoint straddling byte 8
|
||||||
|
// and panic, aborting the request task (an unauthenticated DoS). `get(..)`
|
||||||
|
// returns `None` at a non-char-boundary, so a malformed bearer falls through
|
||||||
|
// to "no match" instead of panicking.
|
||||||
|
let Some(prefix) = rest.get(..API_KEY_PREFIX_LEN) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
let mut candidates = match state.keys.find_active_by_prefix(prefix).await {
|
let mut candidates = match state.keys.find_active_by_prefix(prefix).await {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
@@ -508,6 +523,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_key_prefix_slice_is_char_boundary_safe() {
|
||||||
|
// H2 regression (audit 2026-07-11): `rest` is the raw bearer body after
|
||||||
|
// `pic_`, sliced BEFORE any base32 validation. A byte-index slice
|
||||||
|
// `&rest[..API_KEY_PREFIX_LEN]` panics when a multibyte codepoint
|
||||||
|
// straddles byte 8. `€` is 3 bytes, so `aaaaaa€…` puts byte 8 mid-
|
||||||
|
// codepoint — the boundary-safe `get(..)` must return None, not panic.
|
||||||
|
let malformed = "aaaaaa\u{20ac}bbbb";
|
||||||
|
assert!(malformed.len() > API_KEY_PREFIX_LEN);
|
||||||
|
assert!(malformed.get(..API_KEY_PREFIX_LEN).is_none());
|
||||||
|
// A clean ASCII body slices to exactly the indexed prefix.
|
||||||
|
assert_eq!("abcdefghXYZ".get(..API_KEY_PREFIX_LEN), Some("abcdefgh"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn evict_user_drops_only_that_users_entries() {
|
fn evict_user_drops_only_that_users_entries() {
|
||||||
// Audit 2026-06-11 (PrincipalCache revocation-lag).
|
// Audit 2026-06-11 (PrincipalCache revocation-lag).
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
//! external user-facing label.
|
//! external user-facing label.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
|
use picloud_shared::{AppId, AppRole, GroupId, InstanceRole, Principal, Scope, UserId};
|
||||||
|
|
||||||
/// Things a caller can attempt to do. Each app-scoped variant carries
|
/// Things a caller can attempt to do. Each app-scoped variant carries
|
||||||
/// the `AppId` of the resource the action targets — handlers compute
|
/// the `AppId` of the resource the action targets — handlers compute
|
||||||
@@ -37,6 +37,19 @@ use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
|
|||||||
pub enum Capability {
|
pub enum Capability {
|
||||||
/// Create a new app. Owner / admin only.
|
/// Create a new app. Owner / admin only.
|
||||||
InstanceCreateApp,
|
InstanceCreateApp,
|
||||||
|
/// Create a new group (root-level). Owner / admin only — a Member
|
||||||
|
/// creates subgroups under a group they group-admin (gated by
|
||||||
|
/// `GroupAdmin(parent)` at the handler), not via this instance cap.
|
||||||
|
InstanceCreateGroup,
|
||||||
|
/// Read group metadata + list its subgroups/apps. Viewer+ on the
|
||||||
|
/// group (inherited from any ancestor); implicit for admin / owner.
|
||||||
|
GroupRead(GroupId),
|
||||||
|
/// Rename / edit group metadata, move apps into it. Editor+ on the
|
||||||
|
/// group.
|
||||||
|
GroupWrite(GroupId),
|
||||||
|
/// Group settings: delete, reparent, manage group members. group_admin
|
||||||
|
/// on the group (inherited from any ancestor).
|
||||||
|
GroupAdmin(GroupId),
|
||||||
/// Create / update / delete admin_users rows (other than self
|
/// Create / update / delete admin_users rows (other than self
|
||||||
/// password change, which is a separate flow). Owner / admin.
|
/// password change, which is a separate flow). Owner / admin.
|
||||||
InstanceManageUsers,
|
InstanceManageUsers,
|
||||||
@@ -103,6 +116,64 @@ pub enum Capability {
|
|||||||
/// Write (set/delete) a secret in this app's secrets store (v1.1.7).
|
/// Write (set/delete) a secret in this app's secrets store (v1.1.7).
|
||||||
/// Granted to `editor`+, maps to `script:write` on API keys.
|
/// Granted to `editor`+, maps to `script:write` on API keys.
|
||||||
AppSecretsWrite(AppId),
|
AppSecretsWrite(AppId),
|
||||||
|
/// Read this app's resolved config vars (Phase 3). Same trust shape as
|
||||||
|
/// secrets-read — granted to `viewer`+, maps to `script:read`.
|
||||||
|
AppVarsRead(AppId),
|
||||||
|
/// Write (set/delete) an app-owned config var (Phase 3). Granted to
|
||||||
|
/// `editor`+, maps to `script:write`.
|
||||||
|
AppVarsWrite(AppId),
|
||||||
|
/// Read a group's config vars (Phase 3). Resolved via the group
|
||||||
|
/// ancestor walk; viewer+ on the group.
|
||||||
|
GroupVarsRead(GroupId),
|
||||||
|
/// Write (set/delete) a group-owned config var (Phase 3). editor+ on
|
||||||
|
/// the group.
|
||||||
|
GroupVarsWrite(GroupId),
|
||||||
|
/// Read a group-owned secret's VALUE (Phase 3, the human-read gate).
|
||||||
|
/// group_admin on the owning group — distinct from runtime injection,
|
||||||
|
/// which an inheriting app does without this check.
|
||||||
|
GroupSecretsRead(GroupId),
|
||||||
|
/// Write (set/delete) a group-owned secret (Phase 3). editor+ on the
|
||||||
|
/// group.
|
||||||
|
GroupSecretsWrite(GroupId),
|
||||||
|
/// Read (get / list) group-owned scripts (Phase 4). Resolved via the
|
||||||
|
/// group ancestor walk; viewer+ on the group. Maps to `script:read`.
|
||||||
|
GroupScriptsRead(GroupId),
|
||||||
|
/// Create / update / delete a group-owned script (Phase 4). editor+ on
|
||||||
|
/// the group; maps to `script:write` — the same tier as `AppWriteScript`
|
||||||
|
/// for an app, lifted to the group owner.
|
||||||
|
GroupScriptsWrite(GroupId),
|
||||||
|
/// Read a group-owned shared KV collection (§11.6). Resolved via the group
|
||||||
|
/// ancestor walk; viewer+ on the owning group. Maps to `script:read`. The
|
||||||
|
/// reads-open trust model means a script with no principal (anonymous
|
||||||
|
/// public HTTP) bypasses this check — the structural subtree boundary
|
||||||
|
/// (the collection only resolves for apps under the owning group) is the
|
||||||
|
/// hard isolation; this cap refines access for authenticated callers.
|
||||||
|
GroupKvRead(GroupId),
|
||||||
|
/// Write a group-owned shared KV collection (§11.6). editor+ on the owning
|
||||||
|
/// group; maps to `script:write`. Unlike the read cap, a write FAILS CLOSED
|
||||||
|
/// for an anonymous principal — mutation of shared data always requires an
|
||||||
|
/// authenticated caller (enforced at the service layer, not by skipping).
|
||||||
|
GroupKvWrite(GroupId),
|
||||||
|
/// Read a group-owned shared DOCS collection (§11.6). Viewer+ on the owning
|
||||||
|
/// group; same reads-open trust model as `GroupKvRead`.
|
||||||
|
GroupDocsRead(GroupId),
|
||||||
|
/// Write a group-owned shared DOCS collection (§11.6). editor+ on the owning
|
||||||
|
/// group; fails closed for an anonymous principal, like `GroupKvWrite`.
|
||||||
|
GroupDocsWrite(GroupId),
|
||||||
|
/// Read a group-owned shared FILES collection (§11.6). Viewer+ on the owning
|
||||||
|
/// group; same reads-open trust model as `GroupKvRead`.
|
||||||
|
GroupFilesRead(GroupId),
|
||||||
|
/// Write a group-owned shared FILES collection (§11.6). editor+ on the owning
|
||||||
|
/// group; fails closed for an anonymous principal, like `GroupKvWrite`.
|
||||||
|
GroupFilesWrite(GroupId),
|
||||||
|
/// Publish to a group-owned shared TOPIC (§11.6 D2). editor+ on the owning
|
||||||
|
/// group; fails closed for an anonymous principal, like `GroupKvWrite` — a
|
||||||
|
/// publish is a write to shared state.
|
||||||
|
GroupPubsubPublish(GroupId),
|
||||||
|
/// Enqueue into a group-owned shared QUEUE (§11.6 D3). editor+ on the owning
|
||||||
|
/// group; fails closed for an anonymous principal, like `GroupKvWrite` — an
|
||||||
|
/// enqueue is a write to shared state.
|
||||||
|
GroupQueueEnqueue(GroupId),
|
||||||
/// Send an outbound email from a script in this app (v1.1.7). Maps
|
/// Send an outbound email from a script in this app (v1.1.7). Maps
|
||||||
/// to `script:write` on API keys (sending mail is an outbound
|
/// to `script:write` on API keys (sending mail is an outbound
|
||||||
/// side-effect like an HTTP request). Granted to `editor`+.
|
/// side-effect like an HTTP request). Granted to `editor`+.
|
||||||
@@ -154,9 +225,30 @@ impl Capability {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn app_id(self) -> Option<AppId> {
|
pub const fn app_id(self) -> Option<AppId> {
|
||||||
match self {
|
match self {
|
||||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
Self::InstanceCreateApp
|
||||||
None
|
| Self::InstanceManageUsers
|
||||||
}
|
| Self::InstanceManageSettings
|
||||||
|
| Self::InstanceCreateGroup
|
||||||
|
// Group-scoped caps carry a GroupId, not an AppId. They return
|
||||||
|
// None here so a bound API key (which can only target its one
|
||||||
|
// app) is denied group management at the binding layer.
|
||||||
|
| Self::GroupRead(_)
|
||||||
|
| Self::GroupWrite(_)
|
||||||
|
| Self::GroupAdmin(_)
|
||||||
|
| Self::GroupVarsRead(_)
|
||||||
|
| Self::GroupVarsWrite(_)
|
||||||
|
| Self::GroupSecretsRead(_)
|
||||||
|
| Self::GroupSecretsWrite(_)
|
||||||
|
| Self::GroupScriptsRead(_)
|
||||||
|
| Self::GroupScriptsWrite(_)
|
||||||
|
| Self::GroupKvRead(_)
|
||||||
|
| Self::GroupKvWrite(_)
|
||||||
|
| Self::GroupDocsRead(_)
|
||||||
|
| Self::GroupDocsWrite(_)
|
||||||
|
| Self::GroupFilesRead(_)
|
||||||
|
| Self::GroupFilesWrite(_)
|
||||||
|
| Self::GroupPubsubPublish(_)
|
||||||
|
| Self::GroupQueueEnqueue(_) => None,
|
||||||
Self::AppRead(id)
|
Self::AppRead(id)
|
||||||
| Self::AppWriteScript(id)
|
| Self::AppWriteScript(id)
|
||||||
| Self::AppWriteRoute(id)
|
| Self::AppWriteRoute(id)
|
||||||
@@ -174,6 +266,8 @@ impl Capability {
|
|||||||
| Self::AppQueueEnqueue(id)
|
| Self::AppQueueEnqueue(id)
|
||||||
| Self::AppSecretsRead(id)
|
| Self::AppSecretsRead(id)
|
||||||
| Self::AppSecretsWrite(id)
|
| Self::AppSecretsWrite(id)
|
||||||
|
| Self::AppVarsRead(id)
|
||||||
|
| Self::AppVarsWrite(id)
|
||||||
| Self::AppEmailSend(id)
|
| Self::AppEmailSend(id)
|
||||||
| Self::AppManageTriggers(id)
|
| Self::AppManageTriggers(id)
|
||||||
| Self::AppDeadLetterManage(id)
|
| Self::AppDeadLetterManage(id)
|
||||||
@@ -193,15 +287,23 @@ impl Capability {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn required_scope(self) -> Scope {
|
pub const fn required_scope(self) -> Scope {
|
||||||
match self {
|
match self {
|
||||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
Self::InstanceCreateApp
|
||||||
Scope::InstanceAdmin
|
| Self::InstanceManageUsers
|
||||||
}
|
| Self::InstanceManageSettings
|
||||||
|
| Self::InstanceCreateGroup => Scope::InstanceAdmin,
|
||||||
Self::AppRead(_)
|
Self::AppRead(_)
|
||||||
| Self::AppKvRead(_)
|
| Self::AppKvRead(_)
|
||||||
| Self::AppDocsRead(_)
|
| Self::AppDocsRead(_)
|
||||||
| Self::AppFilesRead(_)
|
| Self::AppFilesRead(_)
|
||||||
| Self::AppSecretsRead(_)
|
| Self::AppSecretsRead(_)
|
||||||
| Self::AppUsersRead(_) => Scope::ScriptRead,
|
| Self::AppUsersRead(_)
|
||||||
|
| Self::AppVarsRead(_)
|
||||||
|
| Self::GroupRead(_)
|
||||||
|
| Self::GroupVarsRead(_)
|
||||||
|
| Self::GroupScriptsRead(_)
|
||||||
|
| Self::GroupKvRead(_)
|
||||||
|
| Self::GroupDocsRead(_)
|
||||||
|
| Self::GroupFilesRead(_) => Scope::ScriptRead,
|
||||||
Self::AppWriteScript(_)
|
Self::AppWriteScript(_)
|
||||||
| Self::AppKvWrite(_)
|
| Self::AppKvWrite(_)
|
||||||
| Self::AppDocsWrite(_)
|
| Self::AppDocsWrite(_)
|
||||||
@@ -213,13 +315,29 @@ impl Capability {
|
|||||||
| Self::AppEmailSend(_)
|
| Self::AppEmailSend(_)
|
||||||
| Self::AppUsersWrite(_)
|
| Self::AppUsersWrite(_)
|
||||||
| Self::AppUsersAdmin(_)
|
| Self::AppUsersAdmin(_)
|
||||||
|
| Self::AppVarsWrite(_)
|
||||||
|
// Group-secret WRITE is editor-role-gated (same tier as
|
||||||
|
// GroupVarsWrite), so its API-key scope matches: script:write,
|
||||||
|
// not app:admin. Only the VALUE READ (GroupSecretsRead) sits at
|
||||||
|
// the admin tier below.
|
||||||
|
| Self::GroupSecretsWrite(_)
|
||||||
|
| Self::GroupScriptsWrite(_)
|
||||||
|
| Self::GroupKvWrite(_)
|
||||||
|
| Self::GroupDocsWrite(_)
|
||||||
|
| Self::GroupFilesWrite(_)
|
||||||
|
| Self::GroupPubsubPublish(_)
|
||||||
|
| Self::GroupQueueEnqueue(_)
|
||||||
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
||||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||||
Self::AppAdmin(_)
|
Self::AppAdmin(_)
|
||||||
| Self::AppManageTriggers(_)
|
| Self::AppManageTriggers(_)
|
||||||
| Self::AppDeadLetterManage(_)
|
| Self::AppDeadLetterManage(_)
|
||||||
| Self::AppTopicManage(_) => Scope::AppAdmin,
|
| Self::AppTopicManage(_)
|
||||||
|
| Self::GroupWrite(_)
|
||||||
|
| Self::GroupAdmin(_)
|
||||||
|
| Self::GroupVarsWrite(_)
|
||||||
|
| Self::GroupSecretsRead(_) => Scope::AppAdmin,
|
||||||
Self::AppLogRead(_) => Scope::LogRead,
|
Self::AppLogRead(_) => Scope::LogRead,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,11 +348,41 @@ impl Capability {
|
|||||||
/// means unit tests can stub it.
|
/// means unit tests can stub it.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait AuthzRepo: Send + Sync {
|
pub trait AuthzRepo: Send + Sync {
|
||||||
|
/// Direct `app_members` row for (user, app). The single-row lookup
|
||||||
|
/// used by member-management surfaces and as the fallback below.
|
||||||
async fn membership(
|
async fn membership(
|
||||||
&self,
|
&self,
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
) -> Result<Option<AppRole>, AuthzError>;
|
) -> Result<Option<AppRole>, AuthzError>;
|
||||||
|
|
||||||
|
/// Highest *effective* role on `app_id` (hierarchy-aware RBAC, §5.3):
|
||||||
|
/// the app's own `app_members` row folded with every `group_members`
|
||||||
|
/// row on any ancestor group, max-by-authority. This is what `can()`
|
||||||
|
/// consults so a `group_admin` on an ancestor is implicitly app_admin
|
||||||
|
/// on the app.
|
||||||
|
///
|
||||||
|
/// Default = direct membership only (no inheritance), so the many test
|
||||||
|
/// stubs that model no group tree keep their existing behavior; the
|
||||||
|
/// Postgres repo overrides this with an ancestor-walking CTE.
|
||||||
|
async fn effective_app_role(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
self.membership(user_id, app_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highest effective role on a *group* node — the group's own
|
||||||
|
/// ancestor walk over `group_members`. Gates the group-management
|
||||||
|
/// capabilities. Default = no grant; the Postgres repo overrides it.
|
||||||
|
async fn effective_group_role(
|
||||||
|
&self,
|
||||||
|
_user_id: UserId,
|
||||||
|
_group_id: GroupId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Repo errors surface here so handlers can map them to 500 without
|
/// Repo errors surface here so handlers can map them to 500 without
|
||||||
@@ -310,6 +458,30 @@ pub enum AuthzDenied {
|
|||||||
Repo(#[from] AuthzError),
|
Repo(#[from] AuthzError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run [`require`] for a known principal and map the `AuthzDenied` verdict onto
|
||||||
|
/// the caller's service-specific error via `forbidden`/`backend`. The single
|
||||||
|
/// home for the `Denied → forbidden() / Repo(e) → backend(e)` mapping that
|
||||||
|
/// [`script_gate`], [`script_gate_require_principal`], and the stateful services
|
||||||
|
/// share.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// `forbidden()` on `AuthzDenied::Denied`; `backend(repo_err.to_string())` on
|
||||||
|
/// `AuthzDenied::Repo`.
|
||||||
|
pub async fn require_mapped<E>(
|
||||||
|
repo: &dyn AuthzRepo,
|
||||||
|
principal: &Principal,
|
||||||
|
cap: Capability,
|
||||||
|
forbidden: impl FnOnce() -> E,
|
||||||
|
backend: impl FnOnce(String) -> E,
|
||||||
|
) -> Result<(), E> {
|
||||||
|
match require(repo, principal, cap).await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(AuthzDenied::Denied) => Err(forbidden()),
|
||||||
|
Err(AuthzDenied::Repo(e)) => Err(backend(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Script-as-gate authz: anonymous public-HTTP scripts skip the check
|
/// Script-as-gate authz: anonymous public-HTTP scripts skip the check
|
||||||
/// (`cx.principal` is `None`); authenticated callers must hold `cap`.
|
/// (`cx.principal` is `None`); authenticated callers must hold `cap`.
|
||||||
///
|
///
|
||||||
@@ -334,11 +506,31 @@ pub async fn script_gate<E>(
|
|||||||
let Some(principal) = cx.principal.as_ref() else {
|
let Some(principal) = cx.principal.as_ref() else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
match require(repo, principal, cap).await {
|
require_mapped(repo, principal, cap, forbidden, backend).await
|
||||||
Ok(()) => Ok(()),
|
}
|
||||||
Err(AuthzDenied::Denied) => Err(forbidden()),
|
|
||||||
Err(AuthzDenied::Repo(e)) => Err(backend(e.to_string())),
|
/// Like [`script_gate`], but **fails closed on an anonymous principal**: a
|
||||||
}
|
/// script with `cx.principal == None` is rejected with `forbidden()` rather
|
||||||
|
/// than skipped. Used for actions that must always be performed by an
|
||||||
|
/// authenticated caller even though the surrounding service skips authz for
|
||||||
|
/// public scripts — e.g. §11.6 group-collection WRITES (reads stay open via
|
||||||
|
/// `script_gate`, writes require an authenticated editor+ on the owning group).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `forbidden()` when the principal is absent or the capability is
|
||||||
|
/// denied, or `backend(repo_err.to_string())` on a repo error.
|
||||||
|
pub async fn script_gate_require_principal<E>(
|
||||||
|
repo: &dyn AuthzRepo,
|
||||||
|
cx: &picloud_shared::SdkCallCx,
|
||||||
|
cap: Capability,
|
||||||
|
forbidden: impl FnOnce() -> E,
|
||||||
|
backend: impl FnOnce(String) -> E,
|
||||||
|
) -> Result<(), E> {
|
||||||
|
let Some(principal) = cx.principal.as_ref() else {
|
||||||
|
return Err(forbidden());
|
||||||
|
};
|
||||||
|
require_mapped(repo, principal, cap, forbidden, backend).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -353,7 +545,36 @@ async fn role_grants(
|
|||||||
match principal.instance_role {
|
match principal.instance_role {
|
||||||
InstanceRole::Owner => Ok(true),
|
InstanceRole::Owner => Ok(true),
|
||||||
InstanceRole::Admin => Ok(admin_grants(cap)),
|
InstanceRole::Admin => Ok(admin_grants(cap)),
|
||||||
InstanceRole::Member => member_grants(repo, principal.user_id, cap).await,
|
InstanceRole::Member => match cap {
|
||||||
|
// Group-management caps resolve against the group ancestor
|
||||||
|
// walk (a group_admin on an ancestor is implicitly admin of
|
||||||
|
// the descendant group). Routed before member_grants because
|
||||||
|
// group caps carry no app_id.
|
||||||
|
Capability::GroupRead(g)
|
||||||
|
| Capability::GroupWrite(g)
|
||||||
|
| Capability::GroupAdmin(g)
|
||||||
|
| Capability::GroupVarsRead(g)
|
||||||
|
| Capability::GroupVarsWrite(g)
|
||||||
|
| Capability::GroupSecretsRead(g)
|
||||||
|
| Capability::GroupSecretsWrite(g)
|
||||||
|
| Capability::GroupScriptsRead(g)
|
||||||
|
| Capability::GroupScriptsWrite(g)
|
||||||
|
| Capability::GroupKvRead(g)
|
||||||
|
| Capability::GroupKvWrite(g)
|
||||||
|
| Capability::GroupDocsRead(g)
|
||||||
|
| Capability::GroupDocsWrite(g)
|
||||||
|
| Capability::GroupFilesRead(g)
|
||||||
|
| Capability::GroupFilesWrite(g)
|
||||||
|
| Capability::GroupPubsubPublish(g)
|
||||||
|
| Capability::GroupQueueEnqueue(g) => {
|
||||||
|
group_member_grants(repo, principal.user_id, cap, g).await
|
||||||
|
}
|
||||||
|
// Creating a root-level group is an instance act — members
|
||||||
|
// can't. (Subgroup creation is gated on GroupAdmin(parent) at
|
||||||
|
// the handler, which routes through the arm above.)
|
||||||
|
Capability::InstanceCreateGroup => Ok(false),
|
||||||
|
_ => member_grants(repo, principal.user_id, cap).await,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,12 +598,62 @@ async fn member_grants(
|
|||||||
let Some(app_id) = cap.app_id() else {
|
let Some(app_id) = cap.app_id() else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let Some(role) = repo.membership(user_id, app_id).await? else {
|
// Effective (inherited) role: the app's own membership folded with any
|
||||||
|
// ancestor group membership. A group_admin on an ancestor group is
|
||||||
|
// implicitly app_admin here.
|
||||||
|
let Some(role) = repo.effective_app_role(user_id, app_id).await? else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
Ok(role_satisfies(role, cap))
|
Ok(role_satisfies(role, cap))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Member-path resolution for the group-management capabilities. Resolves
|
||||||
|
/// the caller's effective role on the group (ancestor walk over
|
||||||
|
/// `group_members`) and checks it covers the requested group action.
|
||||||
|
async fn group_member_grants(
|
||||||
|
repo: &dyn AuthzRepo,
|
||||||
|
user_id: UserId,
|
||||||
|
cap: Capability,
|
||||||
|
group_id: GroupId,
|
||||||
|
) -> Result<bool, AuthzError> {
|
||||||
|
let Some(role) = repo.effective_group_role(user_id, group_id).await? else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
Ok(group_role_satisfies(role, cap))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does the effective group `AppRole` cover the group capability?
|
||||||
|
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
|
||||||
|
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||||
|
match cap {
|
||||||
|
// viewer+ reads group metadata, config vars, scripts, and shared KV.
|
||||||
|
Capability::GroupRead(_)
|
||||||
|
| Capability::GroupVarsRead(_)
|
||||||
|
| Capability::GroupScriptsRead(_)
|
||||||
|
| Capability::GroupKvRead(_)
|
||||||
|
| Capability::GroupDocsRead(_)
|
||||||
|
| Capability::GroupFilesRead(_) => true,
|
||||||
|
// editor+ writes config vars/secrets/scripts and shared KV.
|
||||||
|
Capability::GroupWrite(_)
|
||||||
|
| Capability::GroupVarsWrite(_)
|
||||||
|
| Capability::GroupSecretsWrite(_)
|
||||||
|
| Capability::GroupScriptsWrite(_)
|
||||||
|
| Capability::GroupKvWrite(_)
|
||||||
|
| Capability::GroupDocsWrite(_)
|
||||||
|
| Capability::GroupFilesWrite(_)
|
||||||
|
| Capability::GroupPubsubPublish(_)
|
||||||
|
| Capability::GroupQueueEnqueue(_) => {
|
||||||
|
matches!(role, AppRole::Editor | AppRole::AppAdmin)
|
||||||
|
}
|
||||||
|
// group_admin manages the group + reads secret VALUES (the
|
||||||
|
// human-read gate, distinct from an app's runtime injection).
|
||||||
|
Capability::GroupAdmin(_) | Capability::GroupSecretsRead(_) => {
|
||||||
|
matches!(role, AppRole::AppAdmin)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Does the per-app `AppRole` cover the capability? Viewer can read;
|
/// Does the per-app `AppRole` cover the capability? Viewer can read;
|
||||||
/// Editor adds script/route/log mutations; AppAdmin adds settings,
|
/// Editor adds script/route/log mutations; AppAdmin adds settings,
|
||||||
/// domain claims, and delete. Roles form a strict subset chain, so
|
/// domain claims, and delete. Roles form a strict subset chain, so
|
||||||
@@ -397,6 +668,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
|||||||
| Capability::AppFilesRead(_)
|
| Capability::AppFilesRead(_)
|
||||||
| Capability::AppSecretsRead(_)
|
| Capability::AppSecretsRead(_)
|
||||||
| Capability::AppUsersRead(_)
|
| Capability::AppUsersRead(_)
|
||||||
|
| Capability::AppVarsRead(_)
|
||||||
);
|
);
|
||||||
let in_editor = in_viewer
|
let in_editor = in_viewer
|
||||||
|| matches!(
|
|| matches!(
|
||||||
@@ -412,6 +684,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
|||||||
| Capability::AppSecretsWrite(_)
|
| Capability::AppSecretsWrite(_)
|
||||||
| Capability::AppEmailSend(_)
|
| Capability::AppEmailSend(_)
|
||||||
| Capability::AppUsersWrite(_)
|
| Capability::AppUsersWrite(_)
|
||||||
|
| Capability::AppVarsWrite(_)
|
||||||
| Capability::AppInvoke(_)
|
| Capability::AppInvoke(_)
|
||||||
);
|
);
|
||||||
let in_app_admin = in_editor
|
let in_app_admin = in_editor
|
||||||
@@ -471,16 +744,61 @@ mod tests {
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
/// In-memory `AuthzRepo` so the unit tests don't need a database.
|
/// In-memory `AuthzRepo` so the unit tests don't need a database. Models
|
||||||
|
/// direct app memberships PLUS a group tree (app→group, group→parent)
|
||||||
|
/// and group memberships, so the hierarchy-aware resolution can be
|
||||||
|
/// exercised without Postgres — mirroring the recursive-CTE behavior.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct InMemoryAuthzRepo {
|
struct InMemoryAuthzRepo {
|
||||||
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
|
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
|
||||||
|
app_group: Mutex<HashMap<AppId, GroupId>>,
|
||||||
|
group_parent: Mutex<HashMap<GroupId, Option<GroupId>>>,
|
||||||
|
group_memberships: Mutex<HashMap<(UserId, GroupId), AppRole>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InMemoryAuthzRepo {
|
impl InMemoryAuthzRepo {
|
||||||
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
|
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
|
||||||
self.memberships.lock().await.insert((user, app), role);
|
self.memberships.lock().await.insert((user, app), role);
|
||||||
}
|
}
|
||||||
|
/// Register a group node and its parent (`None` = root).
|
||||||
|
async fn add_group(&self, group: GroupId, parent: Option<GroupId>) {
|
||||||
|
self.group_parent.lock().await.insert(group, parent);
|
||||||
|
}
|
||||||
|
/// Place an app under a group.
|
||||||
|
async fn put_app(&self, app: AppId, group: GroupId) {
|
||||||
|
self.app_group.lock().await.insert(app, group);
|
||||||
|
}
|
||||||
|
/// Grant a group-level role.
|
||||||
|
async fn grant_group(&self, user: UserId, group: GroupId, role: AppRole) {
|
||||||
|
self.group_memberships
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert((user, group), role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold every ancestor group membership starting at `group`,
|
||||||
|
/// max-by-authority, into `acc`.
|
||||||
|
async fn fold_group_chain(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
mut group: Option<GroupId>,
|
||||||
|
mut acc: Option<AppRole>,
|
||||||
|
) -> Option<AppRole> {
|
||||||
|
let memberships = self.group_memberships.lock().await;
|
||||||
|
let parents = self.group_parent.lock().await;
|
||||||
|
let mut hops = 0u32;
|
||||||
|
while let Some(g) = group {
|
||||||
|
if let Some(r) = memberships.get(&(user_id, g)).copied() {
|
||||||
|
acc = Some(acc.map_or(r, |a| a.max(r)));
|
||||||
|
}
|
||||||
|
hops += 1;
|
||||||
|
if hops > 64 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
group = parents.get(&g).copied().flatten();
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -497,6 +815,29 @@ mod tests {
|
|||||||
.get(&(user_id, app_id))
|
.get(&(user_id, app_id))
|
||||||
.copied())
|
.copied())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn effective_app_role(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
let direct = self
|
||||||
|
.memberships
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&(user_id, app_id))
|
||||||
|
.copied();
|
||||||
|
let start = self.app_group.lock().await.get(&app_id).copied();
|
||||||
|
Ok(self.fold_group_chain(user_id, start, direct).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn effective_group_role(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
group_id: GroupId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
Ok(self.fold_group_chain(user_id, Some(group_id), None).await)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn principal(role: InstanceRole) -> Principal {
|
fn principal(role: InstanceRole) -> Principal {
|
||||||
@@ -857,12 +1198,382 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Hierarchy-aware RBAC (Phase 2 groups)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn group_admin_on_ancestor_is_implicit_app_admin() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let acme = GroupId::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.add_group(acme, None).await;
|
||||||
|
repo.put_app(app, acme).await;
|
||||||
|
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
// No app_members row — authority comes purely from the group.
|
||||||
|
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||||
|
|
||||||
|
for cap in [
|
||||||
|
Capability::AppRead(app),
|
||||||
|
Capability::AppWriteScript(app),
|
||||||
|
Capability::AppAdmin(app),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
can(&repo, &p, cap).await.unwrap().is_allow(),
|
||||||
|
"inherited group_admin denied {cap:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn inherited_role_takes_the_max_of_direct_and_ancestor() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let acme = GroupId::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.add_group(acme, None).await;
|
||||||
|
repo.put_app(app, acme).await;
|
||||||
|
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
// Direct viewer on the app, app_admin via the ancestor group:
|
||||||
|
// the higher (app_admin) wins.
|
||||||
|
repo.grant(p.user_id, app, AppRole::Viewer).await;
|
||||||
|
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||||
|
|
||||||
|
assert!(can(&repo, &p, Capability::AppAdmin(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn group_role_inherits_down_a_multi_level_tree() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let root = GroupId::new();
|
||||||
|
let team = GroupId::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.add_group(root, None).await;
|
||||||
|
repo.add_group(team, Some(root)).await;
|
||||||
|
repo.put_app(app, team).await;
|
||||||
|
|
||||||
|
// Editor two levels up flows down to the app as editor.
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
repo.grant_group(p.user_id, root, AppRole::Editor).await;
|
||||||
|
|
||||||
|
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
"editor must not get app_admin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn group_membership_grants_no_instance_capabilities() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let acme = GroupId::new();
|
||||||
|
repo.add_group(acme, None).await;
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||||
|
|
||||||
|
for cap in [
|
||||||
|
Capability::InstanceCreateApp,
|
||||||
|
Capability::InstanceCreateGroup,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, cap).await.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
"group_admin must not grant instance cap {cap:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn group_admin_walks_ancestors_for_group_caps() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let root = GroupId::new();
|
||||||
|
let team = GroupId::new();
|
||||||
|
repo.add_group(root, None).await;
|
||||||
|
repo.add_group(team, Some(root)).await;
|
||||||
|
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
repo.grant_group(p.user_id, root, AppRole::AppAdmin).await;
|
||||||
|
|
||||||
|
// group_admin at root ⇒ admin of the descendant group.
|
||||||
|
assert!(can(&repo, &p, Capability::GroupAdmin(team))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert!(can(&repo, &p, Capability::GroupWrite(team))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
|
||||||
|
// An unrelated member gets nothing.
|
||||||
|
let outsider = principal(InstanceRole::Member);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &outsider, Capability::GroupRead(team))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn group_kv_caps_resolve_by_role_up_the_chain() {
|
||||||
|
// §11.6: shared-KV read is viewer+, write is editor+, both resolved via
|
||||||
|
// the group ancestor walk; an outsider gets nothing.
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let root = GroupId::new();
|
||||||
|
let team = GroupId::new();
|
||||||
|
repo.add_group(root, None).await;
|
||||||
|
repo.add_group(team, Some(root)).await;
|
||||||
|
|
||||||
|
// A viewer at root can READ a descendant group's shared KV but not write.
|
||||||
|
let viewer = principal(InstanceRole::Member);
|
||||||
|
repo.grant_group(viewer.user_id, root, AppRole::Viewer)
|
||||||
|
.await;
|
||||||
|
assert!(can(&repo, &viewer, Capability::GroupKvRead(team))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &viewer, Capability::GroupKvWrite(team))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
|
||||||
|
// An editor at root can WRITE it.
|
||||||
|
let editor = principal(InstanceRole::Member);
|
||||||
|
repo.grant_group(editor.user_id, root, AppRole::Editor)
|
||||||
|
.await;
|
||||||
|
assert!(can(&repo, &editor, Capability::GroupKvWrite(team))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
|
||||||
|
// An unrelated member gets neither.
|
||||||
|
let outsider = principal(InstanceRole::Member);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &outsider, Capability::GroupKvRead(team))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group caps carry no app_id, so a bound key is denied at the binding
|
||||||
|
// layer regardless of role.
|
||||||
|
let bound = Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: Some(vec![Scope::ScriptWrite]),
|
||||||
|
app_binding: Some(AppId::new()),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &bound, Capability::GroupKvWrite(team))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §11.6 D2/D3: shared-topic PUBLISH and shared-queue ENQUEUE are writes, so
|
||||||
|
/// they require editor+ on the owning group. The three older group data caps
|
||||||
|
/// (KV/docs/files) have this test; these two — the newest, and the ones whose
|
||||||
|
/// blast radius is an entire subtree's handlers — did not. A `role_satisfies`
|
||||||
|
/// regression that let a Viewer publish/enqueue would go uncaught otherwise.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn group_pubsub_and_queue_write_caps_require_editor() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let root = GroupId::new();
|
||||||
|
let team = GroupId::new();
|
||||||
|
repo.add_group(root, None).await;
|
||||||
|
repo.add_group(team, Some(root)).await;
|
||||||
|
|
||||||
|
// A Viewer at root is denied BOTH writes on a descendant group.
|
||||||
|
let viewer = principal(InstanceRole::Member);
|
||||||
|
repo.grant_group(viewer.user_id, root, AppRole::Viewer)
|
||||||
|
.await;
|
||||||
|
for cap in [
|
||||||
|
Capability::GroupPubsubPublish(team),
|
||||||
|
Capability::GroupQueueEnqueue(team),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &viewer, cap).await.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
"a Viewer must not publish/enqueue into a shared group collection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An Editor at root is allowed both (resolved up the chain).
|
||||||
|
let editor = principal(InstanceRole::Member);
|
||||||
|
repo.grant_group(editor.user_id, root, AppRole::Editor)
|
||||||
|
.await;
|
||||||
|
for cap in [
|
||||||
|
Capability::GroupPubsubPublish(team),
|
||||||
|
Capability::GroupQueueEnqueue(team),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
can(&repo, &editor, cap).await.unwrap().is_allow(),
|
||||||
|
"an Editor on the owning group's ancestor must be allowed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unrelated member gets neither.
|
||||||
|
let outsider = principal(InstanceRole::Member);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &outsider, Capability::GroupPubsubPublish(team))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &outsider, Capability::GroupQueueEnqueue(team))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn group_docs_caps_resolve_by_role_up_the_chain() {
|
||||||
|
// §11.6 docs: same trust shape as shared KV — read is viewer+, write is
|
||||||
|
// editor+, resolved via the group ancestor walk.
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let root = GroupId::new();
|
||||||
|
let team = GroupId::new();
|
||||||
|
repo.add_group(root, None).await;
|
||||||
|
repo.add_group(team, Some(root)).await;
|
||||||
|
|
||||||
|
let viewer = principal(InstanceRole::Member);
|
||||||
|
repo.grant_group(viewer.user_id, root, AppRole::Viewer)
|
||||||
|
.await;
|
||||||
|
assert!(can(&repo, &viewer, Capability::GroupDocsRead(team))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &viewer, Capability::GroupDocsWrite(team))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
|
||||||
|
let editor = principal(InstanceRole::Member);
|
||||||
|
repo.grant_group(editor.user_id, root, AppRole::Editor)
|
||||||
|
.await;
|
||||||
|
assert!(can(&repo, &editor, Capability::GroupDocsWrite(team))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
|
||||||
|
let outsider = principal(InstanceRole::Member);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &outsider, Capability::GroupDocsRead(team))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn group_files_caps_resolve_by_role_up_the_chain() {
|
||||||
|
// §11.6 files: same trust shape as shared KV/docs — read is viewer+,
|
||||||
|
// write is editor+, resolved via the group ancestor walk.
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let root = GroupId::new();
|
||||||
|
let team = GroupId::new();
|
||||||
|
repo.add_group(root, None).await;
|
||||||
|
repo.add_group(team, Some(root)).await;
|
||||||
|
|
||||||
|
let viewer = principal(InstanceRole::Member);
|
||||||
|
repo.grant_group(viewer.user_id, root, AppRole::Viewer)
|
||||||
|
.await;
|
||||||
|
assert!(can(&repo, &viewer, Capability::GroupFilesRead(team))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &viewer, Capability::GroupFilesWrite(team))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
|
||||||
|
let editor = principal(InstanceRole::Member);
|
||||||
|
repo.grant_group(editor.user_id, root, AppRole::Editor)
|
||||||
|
.await;
|
||||||
|
assert!(can(&repo, &editor, Capability::GroupFilesWrite(team))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
|
||||||
|
let outsider = principal(InstanceRole::Member);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &outsider, Capability::GroupFilesRead(team))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_implicitly_manages_the_whole_group_tree() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let g = GroupId::new();
|
||||||
|
let p = principal(InstanceRole::Admin);
|
||||||
|
for cap in [
|
||||||
|
Capability::InstanceCreateGroup,
|
||||||
|
Capability::GroupRead(g),
|
||||||
|
Capability::GroupWrite(g),
|
||||||
|
Capability::GroupAdmin(g),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
can(&repo, &p, cap).await.unwrap().is_allow(),
|
||||||
|
"admin denied group cap {cap:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bound_key_cannot_manage_groups() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let g = GroupId::new();
|
||||||
|
let p = Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: Some(vec![Scope::AppAdmin]),
|
||||||
|
app_binding: Some(AppId::new()),
|
||||||
|
};
|
||||||
|
// Group caps carry no app_id, so a bound key is denied at the
|
||||||
|
// binding layer regardless of role/scope.
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::GroupAdmin(g)).await.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_role_max_is_authority_ordered() {
|
||||||
|
assert_eq!(AppRole::Viewer.max(AppRole::AppAdmin), AppRole::AppAdmin);
|
||||||
|
assert_eq!(AppRole::Editor.max(AppRole::Viewer), AppRole::Editor);
|
||||||
|
assert_eq!(AppRole::AppAdmin.max(AppRole::Editor), AppRole::AppAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capability_app_id_extraction() {
|
fn capability_app_id_extraction() {
|
||||||
let app = AppId::new();
|
let app = AppId::new();
|
||||||
assert_eq!(Capability::InstanceCreateApp.app_id(), None);
|
assert_eq!(Capability::InstanceCreateApp.app_id(), None);
|
||||||
assert_eq!(Capability::AppRead(app).app_id(), Some(app));
|
assert_eq!(Capability::AppRead(app).app_id(), Some(app));
|
||||||
assert_eq!(Capability::AppAdmin(app).app_id(), Some(app));
|
assert_eq!(Capability::AppAdmin(app).app_id(), Some(app));
|
||||||
|
// Group caps are not app-scoped.
|
||||||
|
assert_eq!(Capability::GroupAdmin(GroupId::new()).app_id(), None);
|
||||||
|
assert_eq!(Capability::InstanceCreateGroup.app_id(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
158
crates/manager-core/src/config_api.rs
Normal file
158
crates/manager-core/src/config_api.rs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
//! `GET /api/v1/admin/apps/{id}/config/effective` — the resolved config an
|
||||||
|
//! app actually sees: every inherited var (with its value + provenance) and
|
||||||
|
//! every inherited secret (MASKED — name/owner/scope only, never the value).
|
||||||
|
//!
|
||||||
|
//! This is the read-only companion to the `vars`/`secrets` admin surfaces.
|
||||||
|
//! It runs the same §3 resolution the `vars::`/`secrets::` SDK calls run, so
|
||||||
|
//! a dev can see exactly what `vars::get`/`secrets::get` would return and
|
||||||
|
//! where each value comes from (`--explain` on the CLI surfaces the
|
||||||
|
//! provenance). Gated by `AppVarsRead` (config is app-readable); the secret
|
||||||
|
//! VALUES are deliberately absent — reading those needs `GroupSecretsRead`
|
||||||
|
//! at the owning group via the dedicated value endpoint.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::{Extension, Router};
|
||||||
|
use picloud_shared::{AppId, Principal};
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||||
|
use crate::config_resolver::{
|
||||||
|
fetch_effective_secret_meta, fetch_var_candidates, resolve, OwnerKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ConfigApiState {
|
||||||
|
pub pool: PgPool,
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_router(state: ConfigApiState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/apps/{id_or_slug}/config/effective", get(effective_config))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owner_json(kind: OwnerKind, id: uuid::Uuid, depth: i32) -> serde_json::Value {
|
||||||
|
json!({ "kind": kind.as_str(), "id": id, "depth": depth })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn effective_config(
|
||||||
|
State(s): State<ConfigApiState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ConfigApiError> {
|
||||||
|
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppVarsRead(app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Vars: resolve to values + provenance (vars are app-readable config).
|
||||||
|
let candidates = fetch_var_candidates(&s.pool, app_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigApiError::Backend(e.to_string()))?;
|
||||||
|
let (values, provenance) = resolve(candidates);
|
||||||
|
let mut vars = serde_json::Map::new();
|
||||||
|
for (key, value) in values {
|
||||||
|
let p = &provenance[&key];
|
||||||
|
vars.insert(
|
||||||
|
key,
|
||||||
|
json!({
|
||||||
|
"value": value,
|
||||||
|
"owner": owner_json(p.owner_kind, p.owner_id, p.depth),
|
||||||
|
"scope": p.scope,
|
||||||
|
"merged_from": p.merged_from
|
||||||
|
.iter()
|
||||||
|
.map(|(d, sc)| json!({ "depth": d, "scope": sc }))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secrets: masked — name + owner/level/scope + status, never the value.
|
||||||
|
let secret_meta = fetch_effective_secret_meta(&s.pool, app_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigApiError::Backend(e.to_string()))?;
|
||||||
|
let mut secrets = serde_json::Map::new();
|
||||||
|
for m in secret_meta {
|
||||||
|
secrets.insert(
|
||||||
|
m.name,
|
||||||
|
json!({
|
||||||
|
"status": "set",
|
||||||
|
"owner": owner_json(m.owner_kind, m.owner_id, m.depth),
|
||||||
|
"scope": m.scope,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(json!({ "vars": vars, "secrets": secrets })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ConfigApiError> {
|
||||||
|
crate::app_repo::resolve_app(apps, ident)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigApiError::Backend(e.to_string()))?
|
||||||
|
.map(|l| l.app.id)
|
||||||
|
.ok_or(ConfigApiError::AppNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ConfigApiError {
|
||||||
|
#[error("app not found")]
|
||||||
|
AppNotFound,
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
#[error("config backend: {0}")]
|
||||||
|
Backend(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for ConfigApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzError> for ConfigApiError {
|
||||||
|
fn from(e: AuthzError) -> Self {
|
||||||
|
Self::AuthzRepo(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ConfigApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, body) = match &self {
|
||||||
|
Self::AppNotFound => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "config effective authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Backend(e) => {
|
||||||
|
tracing::error!(error = %e, "config effective backend error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
472
crates/manager-core/src/config_resolver.rs
Normal file
472
crates/manager-core/src/config_resolver.rs
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
//! The §3 configuration-resolution engine: env-filtered, proximity-first
|
||||||
|
//! inheritance down the group tree.
|
||||||
|
//!
|
||||||
|
//! To resolve a key for app A in environment E (docs/design §3):
|
||||||
|
//! 1. **Env-filter first, per level** — a value scoped `@E` is eligible;
|
||||||
|
//! `*` (env-agnostic) is the fallback. Within one level `@E` beats `*`.
|
||||||
|
//! Env is *eligibility*, not a precedence tier.
|
||||||
|
//! 2. **Nearest level wins** — walk A → parent group → … → root; the
|
||||||
|
//! closest level that defines the (filtered) key wins. Proximity beats
|
||||||
|
//! farther-level env-specificity (a leaf's `*` beats an ancestor's `@E`).
|
||||||
|
//! 3. **Maps deep-merge per key; scalars/arrays replace; deletion is an
|
||||||
|
//! explicit tombstone** that suppresses the inherited key.
|
||||||
|
//!
|
||||||
|
//! The recursive CTE that walks `apps.group_id → groups.parent_id → root`
|
||||||
|
//! mirrors `app_members_repo::effective_app_role`. The env-eligibility
|
||||||
|
//! filter happens in SQL; the §3 merge/replace/tombstone semantics — which
|
||||||
|
//! a window-function pick can't express — happen in the pure `resolve`
|
||||||
|
//! function below, so they're unit-tested without Postgres.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use picloud_shared::AppId;
|
||||||
|
|
||||||
|
/// Shared chain-walk CTE: emits one row per owner-level for an app —
|
||||||
|
/// depth 0 = the app itself (`app_owner` set), then ancestor groups
|
||||||
|
/// nearest-first (`group_owner` set), each carrying the app's environment.
|
||||||
|
/// Depth-bounded `< 64` (the group cycle guard already forbids cycles; this
|
||||||
|
/// is a runaway guard). Bind `$1 = app_id`. Reused by the vars and secret
|
||||||
|
/// resolvers, which each append their own owner-keyed JOIN.
|
||||||
|
pub(crate) const CHAIN_LEVELS_CTE: &str = "\
|
||||||
|
WITH RECURSIVE chain AS ( \
|
||||||
|
SELECT a.id AS app_owner, NULL::uuid AS group_owner, \
|
||||||
|
a.group_id AS next_group, 0 AS depth, a.environment AS app_env \
|
||||||
|
FROM apps a WHERE a.id = $1 \
|
||||||
|
UNION ALL \
|
||||||
|
SELECT NULL::uuid, g.id, g.parent_id, c.depth + 1, c.app_env \
|
||||||
|
FROM groups g JOIN chain c ON g.id = c.next_group \
|
||||||
|
WHERE c.depth < 64 \
|
||||||
|
)";
|
||||||
|
|
||||||
|
/// Group-rooted variant of [`CHAIN_LEVELS_CTE`]: the chain starts at a
|
||||||
|
/// **group** (depth 0 = the group itself, `group_owner` set), then walks
|
||||||
|
/// its ancestor groups nearest-first. There is no `app_owner` level — a
|
||||||
|
/// group origin never sees app-owned rows below it (the §5.5 trust
|
||||||
|
/// boundary). Bind `$1 = group_id`. Used for the lexical module resolver
|
||||||
|
/// when the importing script's defining node is a group.
|
||||||
|
pub(crate) const GROUP_CHAIN_LEVELS_CTE: &str = "\
|
||||||
|
WITH RECURSIVE chain AS ( \
|
||||||
|
SELECT g.id AS group_owner, g.parent_id AS next_group, 0 AS depth \
|
||||||
|
FROM groups g WHERE g.id = $1 \
|
||||||
|
UNION ALL \
|
||||||
|
SELECT g.id, g.parent_id, c.depth + 1 \
|
||||||
|
FROM groups g JOIN chain c ON g.id = c.next_group \
|
||||||
|
WHERE c.depth < 64 \
|
||||||
|
)";
|
||||||
|
|
||||||
|
/// Owner kind of a resolved value, for `--explain` provenance.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum OwnerKind {
|
||||||
|
App,
|
||||||
|
Group,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OwnerKind {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::App => "app",
|
||||||
|
Self::Group => "group",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One eligible (env-filtered) candidate row pulled by the resolver, before
|
||||||
|
/// §3 proximity/merge resolution.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Candidate {
|
||||||
|
/// 0 = the app itself; 1 = its parent group; … (nearest-first).
|
||||||
|
pub depth: i32,
|
||||||
|
pub owner_kind: OwnerKind,
|
||||||
|
pub owner_id: Uuid,
|
||||||
|
/// `*` (env-agnostic) or a concrete environment name.
|
||||||
|
pub scope: String,
|
||||||
|
pub key: String,
|
||||||
|
pub value: Value,
|
||||||
|
pub is_tombstone: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a resolved key came from (for `config --effective --explain`).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Provenance {
|
||||||
|
pub owner_kind: OwnerKind,
|
||||||
|
pub owner_id: Uuid,
|
||||||
|
pub depth: i32,
|
||||||
|
pub scope: String,
|
||||||
|
/// For a deep-merged map, the `(depth, scope)` of every layer that
|
||||||
|
/// contributed, nearest-first. Empty for a plain scalar/array winner.
|
||||||
|
pub merged_from: Vec<(i32, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `@E`-scoped values outrank `*` *within the same level* (§3 step 1).
|
||||||
|
fn env_priority(scope: &str) -> u8 {
|
||||||
|
u8::from(scope != "*")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deep-merge `src` into `dst` per key: nested objects merge recursively,
|
||||||
|
/// everything else is replaced by `src`. Caller merges farthest→nearest so
|
||||||
|
/// nearer layers overwrite.
|
||||||
|
fn deep_merge(dst: &mut Map<String, Value>, src: &Map<String, Value>) {
|
||||||
|
for (k, sv) in src {
|
||||||
|
match (dst.get_mut(k), sv) {
|
||||||
|
(Some(Value::Object(dm)), Value::Object(sm)) => deep_merge(dm, sm),
|
||||||
|
_ => {
|
||||||
|
dst.insert(k.clone(), sv.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve one key's candidate rows (any order in) to its effective value,
|
||||||
|
/// plus the provenance. Returns `None` when a tombstone suppresses the key.
|
||||||
|
fn resolve_one(mut rows: Vec<Candidate>) -> Option<(Value, Provenance)> {
|
||||||
|
// Nearest-first; within a level, `@E` before `*` (§3 steps 1-2).
|
||||||
|
rows.sort_by(|a, b| {
|
||||||
|
a.depth
|
||||||
|
.cmp(&b.depth)
|
||||||
|
.then(env_priority(&b.scope).cmp(&env_priority(&a.scope)))
|
||||||
|
});
|
||||||
|
|
||||||
|
// §3 step 1: env is eligibility, not a merge tier — within a single level
|
||||||
|
// `@E` *suppresses* `*` (it does not layer on top of it). Each level is one
|
||||||
|
// owner (single-parent tree) with at most one `@E` and one `*` row per key
|
||||||
|
// after env-filtering; keep only the level's winner (the `@E` row sorts
|
||||||
|
// first). Without this, a level holding both an `@E` map and a `*` map would
|
||||||
|
// deep-merge them instead of the `@E` shadowing the `*`.
|
||||||
|
rows.dedup_by_key(|r| r.depth);
|
||||||
|
|
||||||
|
// Collect the contiguous run of map-valued rows from the nearest. A
|
||||||
|
// tombstone or scalar/array boundary stops the run (and, if it's the
|
||||||
|
// nearest row, decides the result outright).
|
||||||
|
let mut maps: Vec<&Candidate> = Vec::new();
|
||||||
|
let mut boundary: Option<&Candidate> = None;
|
||||||
|
for r in &rows {
|
||||||
|
if r.is_tombstone {
|
||||||
|
boundary = Some(r);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if r.value.is_object() {
|
||||||
|
maps.push(r);
|
||||||
|
} else {
|
||||||
|
boundary = Some(r);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(first) = maps.first() {
|
||||||
|
// Map run wins; deep-merge farthest→nearest so nearest keys win.
|
||||||
|
let mut acc = Map::new();
|
||||||
|
for m in maps.iter().rev() {
|
||||||
|
if let Value::Object(obj) = &m.value {
|
||||||
|
deep_merge(&mut acc, obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let prov = Provenance {
|
||||||
|
owner_kind: first.owner_kind,
|
||||||
|
owner_id: first.owner_id,
|
||||||
|
depth: first.depth,
|
||||||
|
scope: first.scope.clone(),
|
||||||
|
merged_from: maps.iter().map(|m| (m.depth, m.scope.clone())).collect(),
|
||||||
|
};
|
||||||
|
return Some((Value::Object(acc), prov));
|
||||||
|
}
|
||||||
|
|
||||||
|
// No leading map run: the nearest row is the boundary.
|
||||||
|
match boundary {
|
||||||
|
// Nearest is a tombstone → key deleted.
|
||||||
|
Some(b) if b.is_tombstone => None,
|
||||||
|
// Nearest is a scalar/array → take it verbatim.
|
||||||
|
Some(b) => Some((
|
||||||
|
b.value.clone(),
|
||||||
|
Provenance {
|
||||||
|
owner_kind: b.owner_kind,
|
||||||
|
owner_id: b.owner_id,
|
||||||
|
depth: b.depth,
|
||||||
|
scope: b.scope.clone(),
|
||||||
|
merged_from: Vec::new(),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
// No rows at all (caller only passes non-empty groups).
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a full candidate set (mixed keys, any order) into the effective
|
||||||
|
/// config map + per-key provenance. Pure — unit-tested without Postgres.
|
||||||
|
#[must_use]
|
||||||
|
pub fn resolve(
|
||||||
|
candidates: Vec<Candidate>,
|
||||||
|
) -> (BTreeMap<String, Value>, BTreeMap<String, Provenance>) {
|
||||||
|
let mut by_key: BTreeMap<String, Vec<Candidate>> = BTreeMap::new();
|
||||||
|
for c in candidates {
|
||||||
|
by_key.entry(c.key.clone()).or_default().push(c);
|
||||||
|
}
|
||||||
|
let mut values = BTreeMap::new();
|
||||||
|
let mut provenance = BTreeMap::new();
|
||||||
|
for (key, rows) in by_key {
|
||||||
|
if let Some((v, p)) = resolve_one(rows) {
|
||||||
|
values.insert(key.clone(), v);
|
||||||
|
provenance.insert(key, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(values, provenance)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull every env-eligible `vars` candidate for `app_id`: the app's own
|
||||||
|
/// rows + every ancestor group's rows, filtered to `*` or the app's
|
||||||
|
/// environment, ordered nearest-first.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates sqlx errors.
|
||||||
|
pub async fn fetch_var_candidates(
|
||||||
|
pool: &PgPool,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<Candidate>, sqlx::Error> {
|
||||||
|
let sql = format!(
|
||||||
|
"{CHAIN_LEVELS_CTE} \
|
||||||
|
SELECT c.depth, \
|
||||||
|
CASE WHEN v.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
|
||||||
|
COALESCE(v.app_id, v.group_id) AS owner_id, \
|
||||||
|
v.environment_scope, v.key, v.value, v.is_tombstone \
|
||||||
|
FROM chain c \
|
||||||
|
JOIN vars v ON (v.app_id = c.app_owner OR v.group_id = c.group_owner) \
|
||||||
|
WHERE v.environment_scope = '*' OR v.environment_scope = c.app_env \
|
||||||
|
ORDER BY c.depth ASC"
|
||||||
|
);
|
||||||
|
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The masked, resolved view of one inherited secret for `config/effective`:
|
||||||
|
/// which owner/level/scope supplies it — **never** the value.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EffectiveSecretMeta {
|
||||||
|
pub name: String,
|
||||||
|
pub owner_kind: OwnerKind,
|
||||||
|
pub owner_id: Uuid,
|
||||||
|
pub scope: String,
|
||||||
|
pub depth: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the *names* of every secret an app effectively sees — its own
|
||||||
|
/// plus every ancestor group's, env-filtered, nearest-wins per name. Returns
|
||||||
|
/// masked metadata only (owner/level/scope), so it's safe for an app-level
|
||||||
|
/// principal to read. `DISTINCT ON (name)` with the same ordering as the
|
||||||
|
/// per-name secret resolver guarantees the same winner.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates sqlx errors.
|
||||||
|
pub async fn fetch_effective_secret_meta(
|
||||||
|
pool: &PgPool,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<EffectiveSecretMeta>, sqlx::Error> {
|
||||||
|
let sql = format!(
|
||||||
|
"{CHAIN_LEVELS_CTE} \
|
||||||
|
SELECT DISTINCT ON (s.name) s.name, \
|
||||||
|
CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
|
||||||
|
COALESCE(s.app_id, s.group_id) AS owner_id, \
|
||||||
|
s.environment_scope, c.depth \
|
||||||
|
FROM chain c \
|
||||||
|
JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||||
|
WHERE s.environment_scope = '*' OR s.environment_scope = c.app_env \
|
||||||
|
ORDER BY s.name ASC, c.depth ASC, (s.environment_scope <> '*') DESC"
|
||||||
|
);
|
||||||
|
let rows: Vec<(String, String, Uuid, String, i32)> = sqlx::query_as(&sql)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(
|
||||||
|
|(name, owner_kind, owner_id, scope, depth)| EffectiveSecretMeta {
|
||||||
|
name,
|
||||||
|
owner_kind: if owner_kind == "app" {
|
||||||
|
OwnerKind::App
|
||||||
|
} else {
|
||||||
|
OwnerKind::Group
|
||||||
|
},
|
||||||
|
owner_id,
|
||||||
|
scope,
|
||||||
|
depth,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct VarCandidateRow {
|
||||||
|
depth: i32,
|
||||||
|
owner_kind: String,
|
||||||
|
owner_id: Uuid,
|
||||||
|
environment_scope: String,
|
||||||
|
key: String,
|
||||||
|
value: Value,
|
||||||
|
is_tombstone: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<VarCandidateRow> for Candidate {
|
||||||
|
fn from(r: VarCandidateRow) -> Self {
|
||||||
|
Self {
|
||||||
|
depth: r.depth,
|
||||||
|
owner_kind: if r.owner_kind == "app" {
|
||||||
|
OwnerKind::App
|
||||||
|
} else {
|
||||||
|
OwnerKind::Group
|
||||||
|
},
|
||||||
|
owner_id: r.owner_id,
|
||||||
|
scope: r.environment_scope,
|
||||||
|
key: r.key,
|
||||||
|
value: r.value,
|
||||||
|
is_tombstone: r.is_tombstone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn cand(depth: i32, scope: &str, key: &str, value: Value) -> Candidate {
|
||||||
|
Candidate {
|
||||||
|
depth,
|
||||||
|
owner_kind: if depth == 0 {
|
||||||
|
OwnerKind::App
|
||||||
|
} else {
|
||||||
|
OwnerKind::Group
|
||||||
|
},
|
||||||
|
owner_id: Uuid::nil(),
|
||||||
|
scope: scope.into(),
|
||||||
|
key: key.into(),
|
||||||
|
value,
|
||||||
|
is_tombstone: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tomb(depth: i32, scope: &str, key: &str) -> Candidate {
|
||||||
|
Candidate {
|
||||||
|
is_tombstone: true,
|
||||||
|
value: Value::Null,
|
||||||
|
..cand(depth, scope, key, Value::Null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nearest_level_wins() {
|
||||||
|
// app (depth 0) overrides group (depth 2).
|
||||||
|
let (v, p) = resolve(vec![
|
||||||
|
cand(2, "*", "region", json!("eu")),
|
||||||
|
cand(0, "*", "region", json!("us")),
|
||||||
|
]);
|
||||||
|
assert_eq!(v["region"], json!("us"));
|
||||||
|
assert_eq!(p["region"].depth, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_scoped_beats_agnostic_within_a_level() {
|
||||||
|
let (v, p) = resolve(vec![
|
||||||
|
cand(1, "*", "db_url", json!("default")),
|
||||||
|
cand(1, "staging", "db_url", json!("staging-db")),
|
||||||
|
]);
|
||||||
|
assert_eq!(v["db_url"], json!("staging-db"));
|
||||||
|
assert_eq!(p["db_url"].scope, "staging");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proximity_beats_env_specificity_across_levels() {
|
||||||
|
// §3.2 deliberately-novel call: a leaf's `*` beats an ancestor's `@E`.
|
||||||
|
let (v, _) = resolve(vec![
|
||||||
|
cand(2, "production", "db_url", json!("anc-prod")),
|
||||||
|
cand(0, "*", "db_url", json!("leaf-default")),
|
||||||
|
]);
|
||||||
|
assert_eq!(v["db_url"], json!("leaf-default"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_deep_merge_nearest_wins() {
|
||||||
|
// ancestor sets {title, region}; leaf overrides title, adds locale.
|
||||||
|
let (v, p) = resolve(vec![
|
||||||
|
cand(2, "*", "ui", json!({"title": "Base", "region": "eu"})),
|
||||||
|
cand(0, "*", "ui", json!({"title": "Leaf", "locale": "en"})),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
v["ui"],
|
||||||
|
json!({"title": "Leaf", "region": "eu", "locale": "en"})
|
||||||
|
);
|
||||||
|
assert_eq!(p["ui"].merged_from.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_level_env_map_suppresses_agnostic_map_no_merge() {
|
||||||
|
// §3 step 1: within ONE level, `@E` is not a merge layer over `*` — it
|
||||||
|
// shadows it. A level holding both an `@staging` map and a `*` map must
|
||||||
|
// resolve to the `@staging` map alone, never a deep-merge of the two.
|
||||||
|
let (v, p) = resolve(vec![
|
||||||
|
cand(1, "*", "cfg", json!({"a": 1, "shared": "default"})),
|
||||||
|
cand(1, "staging", "cfg", json!({"shared": "staging"})),
|
||||||
|
]);
|
||||||
|
assert_eq!(v["cfg"], json!({"shared": "staging"}));
|
||||||
|
assert_eq!(p["cfg"].scope, "staging");
|
||||||
|
// Provenance must not list the suppressed `*` layer.
|
||||||
|
assert_eq!(p["cfg"].merged_from, vec![(1, "staging".to_string())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_level_env_map_then_ancestor_map_merges_without_agnostic_sibling() {
|
||||||
|
// The suppressed same-level `*` must also be invisible to cross-level
|
||||||
|
// merge: leaf `@staging` map merges onto the ancestor map, but the
|
||||||
|
// leaf's own `*` map (shadowed at its level) never contributes.
|
||||||
|
let (v, _) = resolve(vec![
|
||||||
|
cand(2, "*", "cfg", json!({"region": "eu", "tier": "base"})),
|
||||||
|
cand(0, "*", "cfg", json!({"leak": "should-not-appear"})),
|
||||||
|
cand(0, "staging", "cfg", json!({"tier": "leaf"})),
|
||||||
|
]);
|
||||||
|
assert_eq!(v["cfg"], json!({"region": "eu", "tier": "leaf"}));
|
||||||
|
assert!(!v["cfg"].as_object().unwrap().contains_key("leak"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nearer_scalar_replaces_whole_inherited_map() {
|
||||||
|
let (v, _) = resolve(vec![
|
||||||
|
cand(2, "*", "x", json!({"a": 1})),
|
||||||
|
cand(0, "*", "x", json!("scalar")),
|
||||||
|
]);
|
||||||
|
assert_eq!(v["x"], json!("scalar"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tombstone_suppresses_inherited_key() {
|
||||||
|
let (v, _) = resolve(vec![
|
||||||
|
cand(2, "*", "secret_flag", json!(true)),
|
||||||
|
tomb(0, "*", "secret_flag"),
|
||||||
|
]);
|
||||||
|
assert!(!v.contains_key("secret_flag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_null_is_a_value_not_a_deletion() {
|
||||||
|
let (v, _) = resolve(vec![cand(0, "*", "k", Value::Null)]);
|
||||||
|
assert!(v.contains_key("k"));
|
||||||
|
assert_eq!(v["k"], Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_merge_stops_at_a_nearer_scalar_boundary() {
|
||||||
|
// nearest map, then a scalar, then a farther map: only the
|
||||||
|
// contiguous top map run merges; the scalar bounds it.
|
||||||
|
let (v, _) = resolve(vec![
|
||||||
|
cand(3, "*", "m", json!({"deep": 1})),
|
||||||
|
cand(2, "*", "m", json!("scalar-boundary")),
|
||||||
|
cand(0, "*", "m", json!({"near": 2})),
|
||||||
|
]);
|
||||||
|
// depth 0 map is the only one above the depth-2 scalar boundary.
|
||||||
|
assert_eq!(v["m"], json!({"near": 2}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,7 +119,7 @@ async fn tick(pool: &PgPool, now: DateTime<Utc>) -> Result<usize, sqlx::Error> {
|
|||||||
d.schedule, d.timezone, d.last_fired_at \
|
d.schedule, d.timezone, d.last_fired_at \
|
||||||
FROM cron_trigger_details d \
|
FROM cron_trigger_details d \
|
||||||
JOIN triggers t ON t.id = d.trigger_id \
|
JOIN triggers t ON t.id = d.trigger_id \
|
||||||
WHERE t.enabled = TRUE \
|
WHERE t.enabled = TRUE AND t.app_id IS NOT NULL \
|
||||||
FOR UPDATE OF d SKIP LOCKED",
|
FOR UPDATE OF d SKIP LOCKED",
|
||||||
)
|
)
|
||||||
.fetch_all(&mut *tx)
|
.fetch_all(&mut *tx)
|
||||||
|
|||||||
48
crates/manager-core/src/dev_email_api.rs
Normal file
48
crates/manager-core/src/dev_email_api.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
//! `GET /api/v1/admin/dev/emails` — dev-only inspection of mail captured
|
||||||
|
//! by the in-memory email sink (G5).
|
||||||
|
//!
|
||||||
|
//! Mounted **only** when the email service is running in dev-capture mode
|
||||||
|
//! (`PICLOUD_DEV_MODE=true` and no SMTP relay configured). In every other
|
||||||
|
//! configuration the route does not exist, so there is no production
|
||||||
|
//! surface here. Capture is instance-wide (the SMTP transport seam can't
|
||||||
|
//! see a script's `app_id`), so the endpoint is instance-wide too and is
|
||||||
|
//! restricted to instance Owners/Admins.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::Json;
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::{Extension, Router};
|
||||||
|
use picloud_shared::{InstanceRole, Principal};
|
||||||
|
|
||||||
|
use crate::email_service::{CapturedEmail, DevEmailSink};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DevEmailState {
|
||||||
|
pub sink: Arc<DevEmailSink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the dev-email router. Callers mount this only when dev-capture
|
||||||
|
/// mode is active (i.e. they hold a `Some(sink)`).
|
||||||
|
pub fn dev_emails_router(state: DevEmailState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/dev/emails", get(list_dev_emails))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_dev_emails(
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
State(state): State<DevEmailState>,
|
||||||
|
) -> Result<Json<Vec<CapturedEmail>>, StatusCode> {
|
||||||
|
// Instance-wide data → require an instance Owner/Admin. A Member
|
||||||
|
// (app-scoped) principal has no business reading every app's mail.
|
||||||
|
if !matches!(
|
||||||
|
principal.instance_role,
|
||||||
|
InstanceRole::Owner | InstanceRole::Admin
|
||||||
|
) {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Ok(Json(state.sink.snapshot()))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
186
crates/manager-core/src/docs_api.rs
Normal file
186
crates/manager-core/src/docs_api.rs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
//! `/api/v1/admin/apps/{id}/docs*` — read-only docs inspection.
|
||||||
|
//!
|
||||||
|
//! Mirrors `kv_api` / `files_api` so the `pic docs … --app` CLI (and a
|
||||||
|
//! future dashboard tab) can browse stored documents without a script.
|
||||||
|
//! The per-group equivalent shipped first (`group_blobs_api`); this is
|
||||||
|
//! its per-app counterpart, filling the one admin-read gap the other
|
||||||
|
//! data-plane services already covered. **Read-only by design** — docs
|
||||||
|
//! writes go through `docs::create/update/delete` in scripts, which emit
|
||||||
|
//! change events the trigger framework depends on; an admin write would
|
||||||
|
//! bypass that, so it is deliberately out of scope (matching kv/files).
|
||||||
|
//!
|
||||||
|
//! Two operations:
|
||||||
|
//! * `GET /apps/{id}/docs?collection=<c>&cursor=&limit=` — list docs in
|
||||||
|
//! a collection (cursor-paginated).
|
||||||
|
//! * `GET /apps/{id}/docs/{collection}/{doc_id}` — fetch one document.
|
||||||
|
//!
|
||||||
|
//! Capability: `AppDocsRead`, resolved against the app loaded from the
|
||||||
|
//! path (same tier the SDK read path uses).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::{Extension, Router};
|
||||||
|
use picloud_shared::{AppId, Principal};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
|
use crate::docs_repo::DocsRepo;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DocsAdminState {
|
||||||
|
pub docs: Arc<dyn DocsRepo>,
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn docs_admin_router(state: DocsAdminState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/apps/{app_id}/docs", get(list_docs))
|
||||||
|
.route("/apps/{app_id}/docs/{collection}/{doc_id}", get(get_doc))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListDocsQuery {
|
||||||
|
pub collection: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors the `group_blobs_api` docs shape so the CLI can share one
|
||||||
|
/// deserialize across `--app` and `--group`.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct DocEntry {
|
||||||
|
id: String,
|
||||||
|
data: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ListDocsResponse {
|
||||||
|
docs: Vec<DocEntry>,
|
||||||
|
next_cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_docs(
|
||||||
|
State(s): State<DocsAdminState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Query(q): Query<ListDocsQuery>,
|
||||||
|
) -> Result<Json<ListDocsResponse>, DocsApiError> {
|
||||||
|
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppDocsRead(app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let page = s
|
||||||
|
.docs
|
||||||
|
.list(
|
||||||
|
app_id,
|
||||||
|
&q.collection,
|
||||||
|
q.cursor.as_deref(),
|
||||||
|
q.limit.unwrap_or(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DocsApiError::Backend(e.to_string()))?;
|
||||||
|
Ok(Json(ListDocsResponse {
|
||||||
|
docs: page
|
||||||
|
.docs
|
||||||
|
.into_iter()
|
||||||
|
.map(|d| DocEntry {
|
||||||
|
id: d.id.to_string(),
|
||||||
|
data: d.data,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
next_cursor: page.next_cursor,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_doc(
|
||||||
|
State(s): State<DocsAdminState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, collection, doc_id)): Path<(String, String, String)>,
|
||||||
|
) -> Result<Json<serde_json::Value>, DocsApiError> {
|
||||||
|
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppDocsRead(app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let id = doc_id.parse::<Uuid>().map_err(|_| DocsApiError::NotFound)?;
|
||||||
|
let row = s
|
||||||
|
.docs
|
||||||
|
.get(app_id, &collection, id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DocsApiError::Backend(e.to_string()))?
|
||||||
|
.ok_or(DocsApiError::NotFound)?;
|
||||||
|
Ok(Json(json!({ "id": row.id.to_string(), "data": row.data })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, DocsApiError> {
|
||||||
|
crate::app_repo::resolve_app(apps, ident)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DocsApiError::Backend(e.to_string()))?
|
||||||
|
.map(|l| l.app.id)
|
||||||
|
.ok_or(DocsApiError::AppNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum DocsApiError {
|
||||||
|
#[error("app not found")]
|
||||||
|
AppNotFound,
|
||||||
|
#[error("document not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
#[error("docs backend: {0}")]
|
||||||
|
Backend(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for DocsApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for DocsApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
let (status, body) = match &self {
|
||||||
|
Self::AppNotFound | Self::NotFound => {
|
||||||
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "docs admin authz error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Backend(e) => {
|
||||||
|
tracing::error!(error = %e, "docs admin backend error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user