feat(vars): VarsService + vars:: SDK + config capabilities
Scripts can now read group-inherited config via vars::get(key) / vars::all(). - shared: VarsService trait (read-only get/all) + NoopVarsService; added as the 14th field on the Services bundle. - manager-core: VarsServiceImpl resolves the calling app's config via config_resolver (derives app_id from cx.app_id; AppVarsRead-gated for authed principals, script-as-gate for anon). - executor-core: vars:: Rhai bridge (get/all), registered in the SDK. - authz: AppVarsRead/Write, GroupVarsRead/Write, GroupSecretsRead/Write capabilities (group caps resolve via the Phase-2 ancestor walk; the GroupSecretsRead human-read gate is group_admin-only, distinct from an app's runtime injection). - wired VarsServiceImpl into the picloud binary; updated the executor-core test Services::new call sites. 386 manager-core lib tests green; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ 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 use bridge::{dynamic_to_json, json_to_dynamic};
|
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
||||||
pub use cx::SdkCallCx;
|
pub use cx::SdkCallCx;
|
||||||
@@ -62,6 +63,7 @@ pub fn register_all(
|
|||||||
queue::register(engine, services, cx.clone());
|
queue::register(engine, services, cx.clone());
|
||||||
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());
|
||||||
invoke::register(engine, services, cx, limits, self_engine);
|
invoke::register(engine, services, cx, limits, self_engine);
|
||||||
|
|||||||
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());
|
||||||
|
}
|
||||||
@@ -107,6 +107,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);
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,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));
|
||||||
|
|||||||
@@ -114,6 +114,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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,25 @@ 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),
|
||||||
/// 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`+.
|
||||||
@@ -176,7 +195,11 @@ impl Capability {
|
|||||||
// app) is denied group management at the binding layer.
|
// app) is denied group management at the binding layer.
|
||||||
| Self::GroupRead(_)
|
| Self::GroupRead(_)
|
||||||
| Self::GroupWrite(_)
|
| Self::GroupWrite(_)
|
||||||
| Self::GroupAdmin(_) => None,
|
| Self::GroupAdmin(_)
|
||||||
|
| Self::GroupVarsRead(_)
|
||||||
|
| Self::GroupVarsWrite(_)
|
||||||
|
| Self::GroupSecretsRead(_)
|
||||||
|
| Self::GroupSecretsWrite(_) => None,
|
||||||
Self::AppRead(id)
|
Self::AppRead(id)
|
||||||
| Self::AppWriteScript(id)
|
| Self::AppWriteScript(id)
|
||||||
| Self::AppWriteRoute(id)
|
| Self::AppWriteRoute(id)
|
||||||
@@ -194,6 +217,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)
|
||||||
@@ -223,7 +248,9 @@ impl Capability {
|
|||||||
| Self::AppFilesRead(_)
|
| Self::AppFilesRead(_)
|
||||||
| Self::AppSecretsRead(_)
|
| Self::AppSecretsRead(_)
|
||||||
| Self::AppUsersRead(_)
|
| Self::AppUsersRead(_)
|
||||||
| Self::GroupRead(_) => Scope::ScriptRead,
|
| Self::AppVarsRead(_)
|
||||||
|
| Self::GroupRead(_)
|
||||||
|
| Self::GroupVarsRead(_) => Scope::ScriptRead,
|
||||||
Self::AppWriteScript(_)
|
Self::AppWriteScript(_)
|
||||||
| Self::AppKvWrite(_)
|
| Self::AppKvWrite(_)
|
||||||
| Self::AppDocsWrite(_)
|
| Self::AppDocsWrite(_)
|
||||||
@@ -235,6 +262,7 @@ impl Capability {
|
|||||||
| Self::AppEmailSend(_)
|
| Self::AppEmailSend(_)
|
||||||
| Self::AppUsersWrite(_)
|
| Self::AppUsersWrite(_)
|
||||||
| Self::AppUsersAdmin(_)
|
| Self::AppUsersAdmin(_)
|
||||||
|
| Self::AppVarsWrite(_)
|
||||||
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
||||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||||
@@ -243,7 +271,10 @@ impl Capability {
|
|||||||
| Self::AppDeadLetterManage(_)
|
| Self::AppDeadLetterManage(_)
|
||||||
| Self::AppTopicManage(_)
|
| Self::AppTopicManage(_)
|
||||||
| Self::GroupWrite(_)
|
| Self::GroupWrite(_)
|
||||||
| Self::GroupAdmin(_) => Scope::AppAdmin,
|
| Self::GroupAdmin(_)
|
||||||
|
| Self::GroupVarsWrite(_)
|
||||||
|
| Self::GroupSecretsRead(_)
|
||||||
|
| Self::GroupSecretsWrite(_) => Scope::AppAdmin,
|
||||||
Self::AppLogRead(_) => Scope::LogRead,
|
Self::AppLogRead(_) => Scope::LogRead,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,7 +443,13 @@ async fn role_grants(
|
|||||||
// walk (a group_admin on an ancestor is implicitly admin of
|
// walk (a group_admin on an ancestor is implicitly admin of
|
||||||
// the descendant group). Routed before member_grants because
|
// the descendant group). Routed before member_grants because
|
||||||
// group caps carry no app_id.
|
// group caps carry no app_id.
|
||||||
Capability::GroupRead(g) | Capability::GroupWrite(g) | Capability::GroupAdmin(g) => {
|
Capability::GroupRead(g)
|
||||||
|
| Capability::GroupWrite(g)
|
||||||
|
| Capability::GroupAdmin(g)
|
||||||
|
| Capability::GroupVarsRead(g)
|
||||||
|
| Capability::GroupVarsWrite(g)
|
||||||
|
| Capability::GroupSecretsRead(g)
|
||||||
|
| Capability::GroupSecretsWrite(g) => {
|
||||||
group_member_grants(repo, principal.user_id, cap, g).await
|
group_member_grants(repo, principal.user_id, cap, g).await
|
||||||
}
|
}
|
||||||
// Creating a root-level group is an instance act — members
|
// Creating a root-level group is an instance act — members
|
||||||
@@ -472,9 +509,19 @@ async fn group_member_grants(
|
|||||||
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
|
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
|
||||||
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
|
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||||
match cap {
|
match cap {
|
||||||
Capability::GroupRead(_) => true, // any role can read
|
// viewer+ reads group metadata and config vars.
|
||||||
Capability::GroupWrite(_) => matches!(role, AppRole::Editor | AppRole::AppAdmin),
|
Capability::GroupRead(_) | Capability::GroupVarsRead(_) => true,
|
||||||
Capability::GroupAdmin(_) => matches!(role, AppRole::AppAdmin),
|
// editor+ writes config vars/secrets.
|
||||||
|
Capability::GroupWrite(_)
|
||||||
|
| Capability::GroupVarsWrite(_)
|
||||||
|
| Capability::GroupSecretsWrite(_) => {
|
||||||
|
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,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,6 +540,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!(
|
||||||
@@ -508,6 +556,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
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ pub mod trigger_repo;
|
|||||||
pub mod triggers_api;
|
pub mod triggers_api;
|
||||||
pub mod users_admin_api;
|
pub mod users_admin_api;
|
||||||
pub mod users_service;
|
pub mod users_service;
|
||||||
|
pub mod vars_service;
|
||||||
|
|
||||||
pub use abandoned_repo::{
|
pub use abandoned_repo::{
|
||||||
AbandonedRepo, AbandonedRepoError, NewAbandonedExecution, PostgresAbandonedRepo,
|
AbandonedRepo, AbandonedRepoError, NewAbandonedExecution, PostgresAbandonedRepo,
|
||||||
@@ -220,3 +221,4 @@ pub use trigger_repo::{
|
|||||||
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
|
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
|
||||||
pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState};
|
pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState};
|
||||||
pub use users_service::{UsersServiceConfig, UsersServiceImpl};
|
pub use users_service::{UsersServiceConfig, UsersServiceImpl};
|
||||||
|
pub use vars_service::VarsServiceImpl;
|
||||||
|
|||||||
63
crates/manager-core/src/vars_service.rs
Normal file
63
crates/manager-core/src/vars_service.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
//! `VarsService` — the runtime read path for group-inherited config.
|
||||||
|
//!
|
||||||
|
//! Resolves the calling app's config (own rows + inherited group rows,
|
||||||
|
//! env-filtered, proximity-first; see `config_resolver`) and exposes it to
|
||||||
|
//! scripts as `vars::get(key)` / `vars::all()`. Read-only from scripts;
|
||||||
|
//! writes go through the admin API. Like every SDK service, it derives the
|
||||||
|
//! app from `cx.app_id` — never a script argument — so cross-app isolation
|
||||||
|
//! holds.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{SdkCallCx, VarsError, VarsService};
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::authz::{self, AuthzRepo, Capability};
|
||||||
|
use crate::config_resolver::{fetch_var_candidates, resolve};
|
||||||
|
|
||||||
|
pub struct VarsServiceImpl {
|
||||||
|
pool: PgPool,
|
||||||
|
authz: Arc<dyn AuthzRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VarsServiceImpl {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool, authz: Arc<dyn AuthzRepo>) -> Self {
|
||||||
|
Self { pool, authz }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authed principals need `AppVarsRead`; anonymous public-HTTP scripts
|
||||||
|
/// (`principal: None`) read freely under script-as-gate semantics.
|
||||||
|
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), VarsError> {
|
||||||
|
if let Some(ref principal) = cx.principal {
|
||||||
|
authz::require(&*self.authz, principal, Capability::AppVarsRead(cx.app_id))
|
||||||
|
.await
|
||||||
|
.map_err(|_| VarsError::Forbidden)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolved(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
|
||||||
|
let candidates = fetch_var_candidates(&self.pool, cx.app_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| VarsError::Backend(e.to_string()))?;
|
||||||
|
let (values, _provenance) = resolve(candidates);
|
||||||
|
Ok(values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl VarsService for VarsServiceImpl {
|
||||||
|
async fn get(&self, cx: &SdkCallCx, key: &str) -> Result<Option<Value>, VarsError> {
|
||||||
|
self.check_read(cx).await?;
|
||||||
|
Ok(self.resolved(cx).await?.remove(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn all(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
|
||||||
|
self.check_read(cx).await?;
|
||||||
|
self.resolved(cx).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ use picloud_manager_core::{
|
|||||||
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
|
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
|
||||||
RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl,
|
RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl,
|
||||||
SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo,
|
SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo,
|
||||||
TriggersState, UsersServiceConfig, UsersServiceImpl,
|
TriggersState, UsersServiceConfig, UsersServiceImpl, VarsServiceImpl,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
||||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||||
@@ -322,6 +322,8 @@ pub async fn build_app(
|
|||||||
// under script-as-gate semantics.
|
// under script-as-gate semantics.
|
||||||
.with_authz(authz.clone()),
|
.with_authz(authz.clone()),
|
||||||
);
|
);
|
||||||
|
let vars: Arc<dyn picloud_shared::VarsService> =
|
||||||
|
Arc::new(VarsServiceImpl::new(pool.clone(), authz.clone()));
|
||||||
let services = Services::new(
|
let services = Services::new(
|
||||||
kv,
|
kv,
|
||||||
docs,
|
docs,
|
||||||
@@ -336,6 +338,7 @@ pub async fn build_app(
|
|||||||
users.clone(),
|
users.clone(),
|
||||||
queue,
|
queue,
|
||||||
invoke,
|
invoke,
|
||||||
|
vars,
|
||||||
);
|
);
|
||||||
// v1.1.9: keep the invoke depth bound aligned with the dispatcher's
|
// v1.1.9: keep the invoke depth bound aligned with the dispatcher's
|
||||||
// trigger-depth bound (same counter under the hood).
|
// trigger-depth bound (same counter under the hood).
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ pub mod subscriber_token;
|
|||||||
pub mod trigger_event;
|
pub mod trigger_event;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod validator;
|
pub mod validator;
|
||||||
|
pub mod vars;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|
||||||
/// serde `default` for `enabled`-style boolean fields that should default to
|
/// serde `default` for `enabled`-style boolean fields that should default to
|
||||||
@@ -101,4 +102,5 @@ pub use users::{
|
|||||||
UsersService,
|
UsersService,
|
||||||
};
|
};
|
||||||
pub use validator::{ScriptValidator, ValidatedScript, ValidationError};
|
pub use validator::{ScriptValidator, ValidatedScript, ValidationError};
|
||||||
|
pub use vars::{NoopVarsService, VarsError, VarsService};
|
||||||
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
|
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ use crate::{
|
|||||||
KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService,
|
KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService,
|
||||||
NoopEventEmitter, NoopFilesService, NoopHttpService, NoopInvokeService, NoopKvService,
|
NoopEventEmitter, NoopFilesService, NoopHttpService, NoopInvokeService, NoopKvService,
|
||||||
NoopModuleSource, NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService,
|
NoopModuleSource, NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService,
|
||||||
PubsubService, QueueService, SecretsService, ServiceEventEmitter, UsersService,
|
NoopVarsService, PubsubService, QueueService, SecretsService, ServiceEventEmitter,
|
||||||
|
UsersService, VarsService,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
||||||
@@ -107,6 +108,12 @@ pub struct Services {
|
|||||||
/// and `invoke_async()` (fire-and-forget through the outbox).
|
/// and `invoke_async()` (fire-and-forget through the outbox).
|
||||||
/// Cross-app invokes are rejected at the service entry point.
|
/// Cross-app invokes are rejected at the service entry point.
|
||||||
pub invoke: Arc<dyn InvokeService>,
|
pub invoke: Arc<dyn InvokeService>,
|
||||||
|
|
||||||
|
/// Group-inherited, env-scoped config (Phase 3). Scripts get
|
||||||
|
/// read-only `vars::{get,all}`; values resolve down the group tree
|
||||||
|
/// (§3). Backed by `config_resolver` over Postgres in the picloud
|
||||||
|
/// binary; `NoopVarsService` in tests that don't read config.
|
||||||
|
pub vars: Arc<dyn VarsService>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Services {
|
impl Services {
|
||||||
@@ -129,6 +136,7 @@ impl Services {
|
|||||||
users: Arc<dyn UsersService>,
|
users: Arc<dyn UsersService>,
|
||||||
queue: Arc<dyn QueueService>,
|
queue: Arc<dyn QueueService>,
|
||||||
invoke: Arc<dyn InvokeService>,
|
invoke: Arc<dyn InvokeService>,
|
||||||
|
vars: Arc<dyn VarsService>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
kv,
|
kv,
|
||||||
@@ -144,6 +152,7 @@ impl Services {
|
|||||||
users,
|
users,
|
||||||
queue,
|
queue,
|
||||||
invoke,
|
invoke,
|
||||||
|
vars,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +177,7 @@ impl Services {
|
|||||||
Arc::new(NoopUsersService),
|
Arc::new(NoopUsersService),
|
||||||
Arc::new(NoopQueueService),
|
Arc::new(NoopQueueService),
|
||||||
Arc::new(NoopInvokeService),
|
Arc::new(NoopInvokeService),
|
||||||
|
Arc::new(NoopVarsService),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
crates/shared/src/vars.rs
Normal file
48
crates/shared/src/vars.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
//! `vars::*` — read-only access from scripts to the app's resolved
|
||||||
|
//! configuration (Phase 3). Values are inherited down the group tree and
|
||||||
|
//! env-filtered per the §3 resolution rule; the resolution happens in
|
||||||
|
//! manager-core (`config_resolver`). Writes go through the admin API, not
|
||||||
|
//! the SDK — scripts only read.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::SdkCallCx;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum VarsError {
|
||||||
|
/// Caller principal lacked `AppVarsRead`. Only raised when
|
||||||
|
/// `cx.principal.is_some()` (public-HTTP scripts skip the check —
|
||||||
|
/// script-as-gate).
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
/// Postgres unavailable, malformed row, etc.
|
||||||
|
#[error("vars backend error: {0}")]
|
||||||
|
Backend(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait VarsService: Send + Sync {
|
||||||
|
/// Resolve a single config key for the calling app's environment.
|
||||||
|
/// `None` if no level defines it (or a tombstone suppresses it).
|
||||||
|
async fn get(&self, cx: &SdkCallCx, key: &str) -> Result<Option<Value>, VarsError>;
|
||||||
|
|
||||||
|
/// The app's fully-resolved config map (every inherited + own key).
|
||||||
|
async fn all(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All-noop fallback for engines that don't wire vars (tests). Every call
|
||||||
|
/// surfaces an explicit error rather than silently returning empty.
|
||||||
|
pub struct NoopVarsService;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl VarsService for NoopVarsService {
|
||||||
|
async fn get(&self, _cx: &SdkCallCx, _key: &str) -> Result<Option<Value>, VarsError> {
|
||||||
|
Err(VarsError::Backend("vars is not wired in".into()))
|
||||||
|
}
|
||||||
|
async fn all(&self, _cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
|
||||||
|
Err(VarsError::Backend("vars is not wired in".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user