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:
MechaCat02
2026-06-24 20:59:23 +02:00
parent 35dbd9f368
commit 343f6d3b4d
22 changed files with 258 additions and 9 deletions

View File

@@ -26,6 +26,7 @@ pub mod retry;
pub mod secrets;
pub mod stdlib;
pub mod users;
pub mod vars;
pub use bridge::{dynamic_to_json, json_to_dynamic};
pub use cx::SdkCallCx;
@@ -62,6 +63,7 @@ pub fn register_all(
queue::register(engine, services, cx.clone());
retry::register(engine, services, cx.clone());
secrets::register(engine, services, cx.clone());
vars::register(engine, services, cx.clone());
email::register(engine, services, cx.clone());
users::register(engine, services, cx.clone());
invoke::register(engine, services, cx, limits, self_engine);

View 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());
}

View File

@@ -107,6 +107,7 @@ async fn original_backend_error_is_logged_at_error_level() {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
let engine = Engine::new(Limits::default(), services);

View File

@@ -104,6 +104,7 @@ fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
)
}

View File

@@ -235,6 +235,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}

View File

@@ -44,6 +44,7 @@ fn engine_with(rec: Arc<RecordingEmail>) -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}

View File

@@ -172,6 +172,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}

View File

@@ -95,6 +95,7 @@ fn engine_with(http: Arc<dyn HttpService>) -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}

View File

@@ -90,6 +90,7 @@ fn build_engine(svc: Arc<FakeInvokeService>) -> Arc<Engine> {
Arc::new(NoopUsersService),
Arc::new(NoopQueueService),
svc,
Arc::new(picloud_shared::NoopVarsService),
);
let engine = Arc::new(Engine::new(Limits::default(), services));
engine.set_self_weak(Arc::downgrade(&engine));

View File

@@ -114,6 +114,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}

View File

@@ -52,6 +52,7 @@ fn make_engine(svc: Arc<RecordingPubsub>) -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}

View File

@@ -66,6 +66,7 @@ fn make_engine(svc: Arc<RecordingQueue>) -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
svc,
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}

View File

@@ -30,6 +30,7 @@ fn build_engine() -> Arc<Engine> {
Arc::new(NoopUsersService),
Arc::new(NoopQueueService),
Arc::new(NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}

View File

@@ -105,6 +105,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}

View File

@@ -99,6 +99,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
Arc::new(Engine::new(Limits::default(), services))
}

View File

@@ -116,6 +116,25 @@ pub enum Capability {
/// Write (set/delete) a secret in this app's secrets store (v1.1.7).
/// Granted to `editor`+, maps to `script:write` on API keys.
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
/// to `script:write` on API keys (sending mail is an outbound
/// side-effect like an HTTP request). Granted to `editor`+.
@@ -176,7 +195,11 @@ impl Capability {
// app) is denied group management at the binding layer.
| Self::GroupRead(_)
| Self::GroupWrite(_)
| Self::GroupAdmin(_) => None,
| Self::GroupAdmin(_)
| Self::GroupVarsRead(_)
| Self::GroupVarsWrite(_)
| Self::GroupSecretsRead(_)
| Self::GroupSecretsWrite(_) => None,
Self::AppRead(id)
| Self::AppWriteScript(id)
| Self::AppWriteRoute(id)
@@ -194,6 +217,8 @@ impl Capability {
| Self::AppQueueEnqueue(id)
| Self::AppSecretsRead(id)
| Self::AppSecretsWrite(id)
| Self::AppVarsRead(id)
| Self::AppVarsWrite(id)
| Self::AppEmailSend(id)
| Self::AppManageTriggers(id)
| Self::AppDeadLetterManage(id)
@@ -223,7 +248,9 @@ impl Capability {
| Self::AppFilesRead(_)
| Self::AppSecretsRead(_)
| Self::AppUsersRead(_)
| Self::GroupRead(_) => Scope::ScriptRead,
| Self::AppVarsRead(_)
| Self::GroupRead(_)
| Self::GroupVarsRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_)
| Self::AppKvWrite(_)
| Self::AppDocsWrite(_)
@@ -235,6 +262,7 @@ impl Capability {
| Self::AppEmailSend(_)
| Self::AppUsersWrite(_)
| Self::AppUsersAdmin(_)
| Self::AppVarsWrite(_)
| Self::AppInvoke(_) => Scope::ScriptWrite,
Self::AppWriteRoute(_) => Scope::RouteWrite,
Self::AppManageDomains(_) => Scope::DomainManage,
@@ -243,7 +271,10 @@ impl Capability {
| Self::AppDeadLetterManage(_)
| Self::AppTopicManage(_)
| Self::GroupWrite(_)
| Self::GroupAdmin(_) => Scope::AppAdmin,
| Self::GroupAdmin(_)
| Self::GroupVarsWrite(_)
| Self::GroupSecretsRead(_)
| Self::GroupSecretsWrite(_) => Scope::AppAdmin,
Self::AppLogRead(_) => Scope::LogRead,
}
}
@@ -412,7 +443,13 @@ async fn role_grants(
// 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::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
}
// 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.
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
match cap {
Capability::GroupRead(_) => true, // any role can read
Capability::GroupWrite(_) => matches!(role, AppRole::Editor | AppRole::AppAdmin),
Capability::GroupAdmin(_) => matches!(role, AppRole::AppAdmin),
// viewer+ reads group metadata and config vars.
Capability::GroupRead(_) | Capability::GroupVarsRead(_) => true,
// 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,
}
}
@@ -493,6 +540,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::AppFilesRead(_)
| Capability::AppSecretsRead(_)
| Capability::AppUsersRead(_)
| Capability::AppVarsRead(_)
);
let in_editor = in_viewer
|| matches!(
@@ -508,6 +556,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::AppSecretsWrite(_)
| Capability::AppEmailSend(_)
| Capability::AppUsersWrite(_)
| Capability::AppVarsWrite(_)
| Capability::AppInvoke(_)
);
let in_app_admin = in_editor

View File

@@ -85,6 +85,7 @@ pub mod trigger_repo;
pub mod triggers_api;
pub mod users_admin_api;
pub mod users_service;
pub mod vars_service;
pub use abandoned_repo::{
AbandonedRepo, AbandonedRepoError, NewAbandonedExecution, PostgresAbandonedRepo,
@@ -220,3 +221,4 @@ pub use trigger_repo::{
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState};
pub use users_service::{UsersServiceConfig, UsersServiceImpl};
pub use vars_service::VarsServiceImpl;

View 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
}
}

View File

@@ -33,7 +33,7 @@ use picloud_manager_core::{
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl,
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::routing::{AppDomainTable, RouteTable};
@@ -322,6 +322,8 @@ pub async fn build_app(
// under script-as-gate semantics.
.with_authz(authz.clone()),
);
let vars: Arc<dyn picloud_shared::VarsService> =
Arc::new(VarsServiceImpl::new(pool.clone(), authz.clone()));
let services = Services::new(
kv,
docs,
@@ -336,6 +338,7 @@ pub async fn build_app(
users.clone(),
queue,
invoke,
vars,
);
// v1.1.9: keep the invoke depth bound aligned with the dispatcher's
// trigger-depth bound (same counter under the hood).

View File

@@ -38,6 +38,7 @@ pub mod subscriber_token;
pub mod trigger_event;
pub mod users;
pub mod validator;
pub mod vars;
pub mod version;
/// serde `default` for `enabled`-style boolean fields that should default to
@@ -101,4 +102,5 @@ pub use users::{
UsersService,
};
pub use validator::{ScriptValidator, ValidatedScript, ValidationError};
pub use vars::{NoopVarsService, VarsError, VarsService};
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};

View File

@@ -24,7 +24,8 @@ use crate::{
KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService,
NoopEventEmitter, NoopFilesService, NoopHttpService, NoopInvokeService, NoopKvService,
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
@@ -107,6 +108,12 @@ pub struct Services {
/// and `invoke_async()` (fire-and-forget through the outbox).
/// Cross-app invokes are rejected at the service entry point.
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 {
@@ -129,6 +136,7 @@ impl Services {
users: Arc<dyn UsersService>,
queue: Arc<dyn QueueService>,
invoke: Arc<dyn InvokeService>,
vars: Arc<dyn VarsService>,
) -> Self {
Self {
kv,
@@ -144,6 +152,7 @@ impl Services {
users,
queue,
invoke,
vars,
}
}
@@ -168,6 +177,7 @@ impl Services {
Arc::new(NoopUsersService),
Arc::new(NoopQueueService),
Arc::new(NoopInvokeService),
Arc::new(NoopVarsService),
)
}
}

48
crates/shared/src/vars.rs Normal file
View 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()))
}
}