diff --git a/crates/manager-core/src/authz.rs b/crates/manager-core/src/authz.rs index 3729fa2..df554d4 100644 --- a/crates/manager-core/src/authz.rs +++ b/crates/manager-core/src/authz.rs @@ -263,6 +263,11 @@ impl Capability { | Self::AppUsersWrite(_) | 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::AppInvoke(_) => Scope::ScriptWrite, Self::AppWriteRoute(_) => Scope::RouteWrite, Self::AppManageDomains(_) => Scope::DomainManage, @@ -273,8 +278,7 @@ impl Capability { | Self::GroupWrite(_) | Self::GroupAdmin(_) | Self::GroupVarsWrite(_) - | Self::GroupSecretsRead(_) - | Self::GroupSecretsWrite(_) => Scope::AppAdmin, + | Self::GroupSecretsRead(_) => Scope::AppAdmin, Self::AppLogRead(_) => Scope::LogRead, } } diff --git a/crates/manager-core/src/config_api.rs b/crates/manager-core/src/config_api.rs new file mode 100644 index 0000000..01861ef --- /dev/null +++ b/crates/manager-core/src/config_api.rs @@ -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, + pub authz: Arc, +} + +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, + Extension(principal): Extension, + Path(id_or_slug): Path, +) -> Result, 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::>(), + }), + ); + } + + // 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 { + 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 for ConfigApiError { + fn from(d: AuthzDenied) -> Self { + match d { + AuthzDenied::Denied => Self::Forbidden, + AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), + } + } +} + +impl From 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() + } +} diff --git a/crates/manager-core/src/config_resolver.rs b/crates/manager-core/src/config_resolver.rs index 7ddd74c..edbd1d5 100644 --- a/crates/manager-core/src/config_resolver.rs +++ b/crates/manager-core/src/config_resolver.rs @@ -228,6 +228,62 @@ pub async fn fetch_var_candidates( 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, 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, diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index ff2daf6..b6980a3 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -31,6 +31,7 @@ pub mod auth_api; pub mod auth_bootstrap; pub mod auth_middleware; pub mod authz; +pub mod config_api; pub mod config_resolver; pub mod cron_scheduler; pub mod dead_letter_repo; @@ -150,6 +151,7 @@ pub use auth_middleware::{ API_KEY_PREFIX, API_KEY_PREFIX_LEN, }; pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision}; +pub use config_api::{config_router, ConfigApiError, ConfigApiState}; pub use cron_scheduler::spawn_cron_scheduler; pub use dead_letter_repo::{ DeadLetterRepo, DeadLetterRepoError, DeadLetterRow, NewDeadLetter, PostgresDeadLetterRepo, diff --git a/crates/manager-core/src/secrets_api.rs b/crates/manager-core/src/secrets_api.rs index 97098ae..a27dd98 100644 --- a/crates/manager-core/src/secrets_api.rs +++ b/crates/manager-core/src/secrets_api.rs @@ -1,16 +1,27 @@ -//! `/api/v1/admin/apps/{id}/secrets*` — secrets admin endpoints -//! (v1.1.7). +//! `/api/v1/admin/{apps,groups}/{id}/secrets*` — secrets admin endpoints. //! -//! * `GET /apps/{id}/secrets` — list names + updated_at +//! * `GET /apps/{id}/secrets` — list app secret names + updated_at //! (NEVER values). -//! * `POST /apps/{id}/secrets` — set/overwrite a secret. -//! * `DELETE /apps/{id}/secrets/{name}` — delete a secret. +//! * `POST /apps/{id}/secrets` — set/overwrite an app secret. +//! * `DELETE /apps/{id}/secrets/{name}` — delete an app secret. +//! * `GET /groups/{id}/secrets` — list group secret names + scope +//! + updated_at (NEVER values). +//! * `POST /groups/{id}/secrets` — set/overwrite a group secret +//! (env-scoped). +//! * `DELETE /groups/{id}/secrets/{name}` — delete a group secret. +//! * `GET /groups/{id}/secrets/{name}/value` — **human value read** — +//! the ONE endpoint that returns plaintext, gated at the owning group. //! -//! Set/delete are gated by `AppSecretsWrite` (→ `script:write`); list by -//! `AppSecretsRead` (→ `script:read`). The list surface deliberately -//! returns only names + timestamps — the dashboard never receives -//! plaintext. Values are encrypted with the process master key before -//! they touch the database (same envelope as the script `secrets::set`). +//! App set/delete are gated by `AppSecretsWrite`, list by `AppSecretsRead`. +//! Group set/list/delete are gated by `GroupSecretsWrite` (editor+); the +//! value-read is gated by `GroupSecretsRead` (group_admin only) — that is +//! the masked-secret boundary: a descendant app's dev can see that a group +//! secret EXISTS (and consume it at runtime via `secrets::get`) but only a +//! principal with read rights AT THE OWNING GROUP can read its value. The +//! owner is resolved FIRST (slug-or-uuid), THEN `authz::require` binds the +//! capability to the resolved owner id — never to a caller-controlled path +//! param. Values are encrypted with the process master key before they +//! touch the database (owner-bound AAD; see `secrets_service`). use std::sync::Arc; @@ -19,14 +30,15 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::get; use axum::{Extension, Router}; -use picloud_shared::{validate_secret_name, AppId, MasterKey, Principal, SecretsError}; +use picloud_shared::{validate_secret_name, AppId, GroupId, MasterKey, Principal, SecretsError}; use serde::Deserialize; use serde_json::json; use crate::app_repo::AppRepository; use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability}; +use crate::group_repo::GroupRepository; use crate::secrets_repo::{SecretOwner, SecretsRepo, SecretsRepoError}; -use crate::secrets_service::seal; +use crate::secrets_service::{open, seal}; /// App secrets are env-agnostic; only group secrets carry a concrete scope. const APP_SECRET_SCOPE: &str = "*"; @@ -35,6 +47,7 @@ const APP_SECRET_SCOPE: &str = "*"; pub struct SecretsState { pub repo: Arc, pub apps: Arc, + pub groups: Arc, pub authz: Arc, pub master_key: MasterKey, pub max_value_bytes: usize, @@ -42,10 +55,25 @@ pub struct SecretsState { pub fn secrets_router(state: SecretsState) -> Router { Router::new() - .route("/apps/{app_id}/secrets", get(list_secrets).post(set_secret)) + .route( + "/apps/{app_id}/secrets", + get(list_app_secrets).post(set_app_secret), + ) .route( "/apps/{app_id}/secrets/{name}", - axum::routing::delete(delete_secret), + axum::routing::delete(delete_app_secret), + ) + .route( + "/groups/{group_id}/secrets", + get(list_group_secrets).post(set_group_secret), + ) + .route( + "/groups/{group_id}/secrets/{name}", + axum::routing::delete(delete_group_secret), + ) + .route( + "/groups/{group_id}/secrets/{name}/value", + get(read_group_secret_value), ) .with_state(state) } @@ -58,9 +86,18 @@ pub struct ListQuery { pub limit: Option, } +#[derive(Debug, Deserialize)] +pub struct EnvQuery { + #[serde(default)] + pub env: Option, +} + #[derive(Debug, serde::Serialize)] struct SecretItem { name: String, + /// Environment scope — `*` for app secrets, possibly a concrete env for + /// group secrets. + env: String, updated_at: chrono::DateTime, } @@ -70,7 +107,23 @@ struct ListSecretsResponse { next_cursor: Option, } -async fn list_secrets( +#[derive(Debug, Deserialize)] +pub struct SetSecretRequest { + pub name: String, + /// Any JSON value — the dashboard sends a single-line string, but + /// maps/arrays/numbers round-trip too (matching `secrets::set`). + pub value: serde_json::Value, + /// Environment scope (group secrets only). `*` (env-agnostic, default) + /// or a concrete env matched against `apps.environment` at resolution. + #[serde(default)] + pub env: Option, +} + +// ---------------------------------------------------------------------------- +// App handlers (env-agnostic, scope `*`) +// ---------------------------------------------------------------------------- + +async fn list_app_secrets( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, @@ -83,36 +136,10 @@ async fn list_secrets( Capability::AppSecretsRead(app_id), ) .await?; - let page = s - .repo - .list_meta( - SecretOwner::App(app_id), - q.cursor.as_deref(), - q.limit.unwrap_or(0), - ) - .await?; - Ok(Json(ListSecretsResponse { - secrets: page - .items - .into_iter() - .map(|m| SecretItem { - name: m.name, - updated_at: m.updated_at, - }) - .collect(), - next_cursor: page.next_cursor, - })) + list_meta(&*s.repo, SecretOwner::App(app_id), &q).await } -#[derive(Debug, Deserialize)] -pub struct SetSecretRequest { - pub name: String, - /// Any JSON value — the dashboard sends a single-line string, but - /// maps/arrays/numbers round-trip too (matching `secrets::set`). - pub value: serde_json::Value, -} - -async fn set_secret( +async fn set_app_secret( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, @@ -125,31 +152,17 @@ async fn set_secret( Capability::AppSecretsWrite(app_id), ) .await?; - validate_secret_name(&input.name)?; - // Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to - // (app_id, name). Same path as the SDK secrets::set. - let owner = SecretOwner::App(app_id); - let (ciphertext, nonce, version) = seal( - &s.master_key, - owner, - &input.name, - &input.value, - s.max_value_bytes, - )?; - s.repo - .set( - owner, - APP_SECRET_SCOPE, - &input.name, - &ciphertext, - &nonce, - version, - ) - .await?; - Ok(StatusCode::NO_CONTENT) + // App secrets are always env-agnostic; reject a stray `env`. + if input.env.as_deref().is_some_and(|e| e != APP_SECRET_SCOPE) { + return Err(SecretsApiError::Invalid( + "app secrets are env-agnostic; set an environment scope on a group secret instead" + .into(), + )); + } + seal_and_store(&s, SecretOwner::App(app_id), APP_SECRET_SCOPE, input).await } -async fn delete_secret( +async fn delete_app_secret( State(s): State, Extension(principal): Extension, Path((id_or_slug, name)): Path<(String, String)>, @@ -161,16 +174,161 @@ async fn delete_secret( Capability::AppSecretsWrite(app_id), ) .await?; - if !s + delete(&*s.repo, SecretOwner::App(app_id), APP_SECRET_SCOPE, &name).await +} + +// ---------------------------------------------------------------------------- +// Group handlers (env-scoped) +// ---------------------------------------------------------------------------- + +async fn list_group_secrets( + State(s): State, + Extension(principal): Extension, + Path(id_or_slug): Path, + Query(q): Query, +) -> Result, SecretsApiError> { + let group_id = resolve_group(&*s.groups, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::GroupSecretsWrite(group_id), + ) + .await?; + list_meta(&*s.repo, SecretOwner::Group(group_id), &q).await +} + +async fn set_group_secret( + State(s): State, + Extension(principal): Extension, + Path(id_or_slug): Path, + Json(input): Json, +) -> Result { + let group_id = resolve_group(&*s.groups, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::GroupSecretsWrite(group_id), + ) + .await?; + let env = input.env.clone().unwrap_or_else(|| APP_SECRET_SCOPE.into()); + validate_env_scope(&env)?; + seal_and_store(&s, SecretOwner::Group(group_id), &env, input).await +} + +async fn delete_group_secret( + State(s): State, + Extension(principal): Extension, + Path((id_or_slug, name)): Path<(String, String)>, + Query(q): Query, +) -> Result { + let group_id = resolve_group(&*s.groups, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::GroupSecretsWrite(group_id), + ) + .await?; + let env = q.env.unwrap_or_else(|| APP_SECRET_SCOPE.into()); + validate_env_scope(&env)?; + delete(&*s.repo, SecretOwner::Group(group_id), &env, &name).await +} + +/// The ONE plaintext-returning endpoint. Gated by `GroupSecretsRead` +/// (group_admin) — the masked-secret boundary. A descendant app's dev can +/// see the secret exists and consume it at runtime, but only a reader at the +/// owning group gets the value here. +async fn read_group_secret_value( + State(s): State, + Extension(principal): Extension, + Path((id_or_slug, name)): Path<(String, String)>, + Query(q): Query, +) -> Result, SecretsApiError> { + let group_id = resolve_group(&*s.groups, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::GroupSecretsRead(group_id), + ) + .await?; + validate_secret_name(&name)?; + let env = q.env.unwrap_or_else(|| APP_SECRET_SCOPE.into()); + validate_env_scope(&env)?; + let owner = SecretOwner::Group(group_id); + let stored = s .repo - .delete(SecretOwner::App(app_id), APP_SECRET_SCOPE, &name) + .get(owner, &env, &name) .await? - { + .ok_or(SecretsApiError::NotFound)?; + let value = open(&s.master_key, owner, &name, &stored).map_err(|e| { + tracing::error!(group_id = %group_id, secret = %name, "group secret could not be decrypted"); + SecretsApiError::from(e) + })?; + Ok(Json(json!({ "name": name, "env": env, "value": value }))) +} + +// ---------------------------------------------------------------------------- +// Shared owner-generic bodies +// ---------------------------------------------------------------------------- + +async fn list_meta( + repo: &dyn SecretsRepo, + owner: SecretOwner, + q: &ListQuery, +) -> Result, SecretsApiError> { + let page = repo + .list_meta(owner, q.cursor.as_deref(), q.limit.unwrap_or(0)) + .await?; + Ok(Json(ListSecretsResponse { + secrets: page + .items + .into_iter() + .map(|m| SecretItem { + name: m.name, + env: m.environment_scope, + updated_at: m.updated_at, + }) + .collect(), + next_cursor: page.next_cursor, + })) +} + +async fn seal_and_store( + s: &SecretsState, + owner: SecretOwner, + env: &str, + input: SetSecretRequest, +) -> Result { + validate_secret_name(&input.name)?; + // Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to the owner+name. + let (ciphertext, nonce, version) = seal( + &s.master_key, + owner, + &input.name, + &input.value, + s.max_value_bytes, + )?; + s.repo + .set(owner, env, &input.name, &ciphertext, &nonce, version) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn delete( + repo: &dyn SecretsRepo, + owner: SecretOwner, + env: &str, + name: &str, +) -> Result { + if !repo.delete(owner, env, name).await? { return Err(SecretsApiError::NotFound); } Ok(StatusCode::NO_CONTENT) } +// ---------------------------------------------------------------------------- +// Resolution + validation +// ---------------------------------------------------------------------------- + async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { crate::app_repo::resolve_app(apps, ident) .await @@ -179,10 +337,58 @@ async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result Result { + let found = if let Ok(uuid) = ident.parse::() { + groups + .get_by_id(uuid.into()) + .await + .map_err(|e| SecretsApiError::Backend(e.to_string()))? + } else { + groups + .get_by_slug(ident) + .await + .map_err(|e| SecretsApiError::Backend(e.to_string()))? + }; + found.map(|g| g.id).ok_or(SecretsApiError::GroupNotFound) +} + +/// Env scope is `*` (env-agnostic) or a kebab env name. Mirrors the vars +/// admin validator so a secret and a var share the same env vocabulary. +fn validate_env_scope(env: &str) -> Result<(), SecretsApiError> { + if env == "*" { + return Ok(()); + } + if env.is_empty() || env.len() > 63 { + return Err(SecretsApiError::Invalid( + "env must be '*' or 1–63 characters".into(), + )); + } + let first = env.chars().next().unwrap(); + if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { + return Err(SecretsApiError::Invalid( + "env must start with a lowercase letter or digit".into(), + )); + } + if !env + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(SecretsApiError::Invalid( + "env may contain only lowercase letters, digits, and hyphens".into(), + )); + } + Ok(()) +} + #[derive(Debug, thiserror::Error)] pub enum SecretsApiError { #[error("app not found")] AppNotFound, + #[error("group not found")] + GroupNotFound, #[error("secret not found")] NotFound, #[error("invalid request: {0}")] @@ -233,7 +439,7 @@ impl From for SecretsApiError { impl IntoResponse for SecretsApiError { fn into_response(self) -> Response { let (status, body) = match &self { - Self::AppNotFound | Self::NotFound => { + Self::AppNotFound | Self::GroupNotFound | Self::NotFound => { (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })) } Self::Invalid(_) => ( diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 0baa70c..7dffc2b 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -513,6 +513,7 @@ pub async fn build_app( let secrets_state = SecretsState { repo: secrets_repo, apps: apps_repo.clone(), + groups: groups_repo.clone(), authz: authz.clone(), master_key, max_value_bytes: secrets_max_value_bytes, @@ -576,6 +577,11 @@ pub async fn build_app( groups: groups_repo.clone(), authz: authz.clone(), }; + let config_state = picloud_manager_core::ConfigApiState { + pool: pool.clone(), + apps: apps_repo.clone(), + authz: authz.clone(), + }; let app_users_admin_state = picloud_manager_core::AppUsersState { apps: apps_state.apps.clone(), authz: authz.clone(), @@ -600,6 +606,7 @@ pub async fn build_app( .merge(app_members_router(app_members_state)) .merge(groups_router(groups_state)) .merge(vars_router(vars_state)) + .merge(picloud_manager_core::config_router(config_state)) .merge(picloud_manager_core::app_users_router( app_users_admin_state, ))