feat(v1.1.7-secrets): secrets SDK + table + admin API + dashboard

Encrypted per-app secrets, reachable from scripts as
secrets::{get,set,delete,list}(name) and managed from the dashboard
Secrets tab. Values are AES-256-GCM-sealed with the process master key
(picloud_shared::crypto) before they touch Postgres; the repo only ever
sees ciphertext + nonce. JSON round-trip preserves Rhai types.

- migration 0023_secrets.sql (PRIMARY KEY (app_id, name)).
- SecretsService trait (picloud-shared) + SecretsServiceImpl + repo
  (manager-core), wired into the Services bundle and Rhai engine.
- Capability::AppSecretsRead/Write (→ script:read / script:write); no
  new Scope variants (seven-scope commitment).
- Admin API GET/POST/DELETE /apps/{id}/secrets (list returns names +
  updated_at, never values).
- build_app now takes a MasterKey, sourced from PICLOUD_SECRET_KEY in
  main.rs; test callers pass a fixed test key.
- 64 KB value cap (PICLOUD_SECRET_MAX_VALUE_BYTES); no ServiceEvent
  emission (secret writes don't fire triggers, by design).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-04 21:37:17 +02:00
parent dc2e4fa01f
commit 2d11090d1a
28 changed files with 1959 additions and 35 deletions

View File

@@ -12,21 +12,22 @@ use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
files_admin_router, migrations, require_authenticated, route_admin_router, topics_router,
triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState,
AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository,
AppMembersRepository, AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo,
DeadLetterRepo, DeadLettersState, Dispatcher, DocsServiceImpl, FilesAdminState, FilesConfig,
FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter,
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
PostgresAppRepository, PostgresAppSecretsRepo, PostgresDeadLetterRepo,
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
PostgresRouteRepository, PostgresScriptRepository, PostgresTopicRepo, PostgresTriggerRepo,
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
RouteRepository, SandboxCeiling, ScriptRepository, SubscriberTokenConfig, TopicRepo,
TopicsState, TriggerConfig, TriggerRepo, TriggersState,
files_admin_router, migrations, require_authenticated, route_admin_router, secrets_router,
topics_router, triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository,
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, AppsState,
AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, Dispatcher, DocsServiceImpl,
FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl,
KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo,
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
PostgresAppSecretsRepo, PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo,
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo,
PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo,
PostgresTopicRepo, PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl,
RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig,
TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState,
};
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
@@ -36,9 +37,9 @@ use picloud_orchestrator_core::{
};
use picloud_shared::{
DeadLetterService, DocsService, ExecutionLogSink, FilesService, HttpService, InboxResolver,
KvService, OutboxWriter, PubsubService, RealtimeAuthority, RealtimeBroadcaster,
ScriptValidator, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION,
WIRE_VERSION,
KvService, MasterKey, OutboxWriter, PubsubService, RealtimeAuthority, RealtimeBroadcaster,
ScriptValidator, SecretsService, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION,
SDK_VERSION, WIRE_VERSION,
};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
@@ -94,7 +95,11 @@ fn read_session_ttl() -> Duration {
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
/// `/version`) stays open — it's the public ingress for user scripts.
#[allow(clippy::too_many_lines)]
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
pub async fn build_app(
pool: PgPool,
auth: AuthDeps,
master_key: MasterKey,
) -> anyhow::Result<Router> {
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
@@ -203,6 +208,20 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
SubscriberTokenConfig::from_env(),
),
);
// v1.1.7 encrypted per-app secrets. Values are AES-256-GCM-sealed
// with the process master key before they touch Postgres; the repo
// only ever sees ciphertext + nonce. The admin surface reuses the
// same repo + master key (see `secrets_state` below).
let secrets_config = SecretsConfig::from_env();
let secrets_max_value_bytes = secrets_config.max_value_bytes;
let secrets_repo: Arc<dyn picloud_manager_core::SecretsRepo> =
Arc::new(PostgresSecretsRepo::new(pool.clone()));
let secrets: Arc<dyn SecretsService> = Arc::new(SecretsServiceImpl::new(
secrets_repo.clone(),
authz.clone(),
master_key.clone(),
secrets_config,
));
let services = Services::new(
kv,
docs,
@@ -212,6 +231,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
http,
files,
pubsub,
secrets,
);
let engine = Arc::new(Engine::new(Limits::default(), services));
@@ -340,6 +360,13 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
authz: authz.clone(),
broadcaster: broadcaster.clone(),
};
let secrets_state = SecretsState {
repo: secrets_repo,
apps: apps_repo.clone(),
authz: authz.clone(),
master_key,
max_value_bytes: secrets_max_value_bytes,
};
let apps_state = AppsState {
apps: apps_repo,
domains: domains_repo,
@@ -384,6 +411,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
.merge(triggers_router(triggers_state))
.merge(files_admin_router(files_admin_state))
.merge(topics_router(topics_state))
.merge(secrets_router(secrets_state))
.merge(dead_letters_router(dead_letters_state))
.layer(from_fn_with_state(
auth_state.clone(),

View File

@@ -39,6 +39,11 @@ async fn run_server() -> anyhow::Result<()> {
let database_url =
std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL is required"))?;
// Source the process master key BEFORE doing any work — an unset or
// malformed PICLOUD_SECRET_KEY is fatal (v1.1.7). The only escape
// hatch is PICLOUD_DEV_MODE=true, which logs a prominent warning.
let master_key = picloud_shared::MasterKey::from_env()?;
let pool = init_db(&database_url).await?;
migrations::run(&pool).await?;
tracing::info!("migrations applied");
@@ -69,7 +74,7 @@ async fn run_server() -> anyhow::Result<()> {
// so a delayed sweep can't extend session lifetimes.
spawn_session_pruner(auth.sessions.clone());
let app = build_app(pool, auth).await?;
let app = build_app(pool, auth, master_key).await?;
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(%addr, "picloud all-in-one listening");

View File

@@ -40,7 +40,13 @@ async fn server_with_app(pool: PgPool) -> (TestServer, String) {
.await
.expect("seed admin");
let app = picloud::build_app(pool, auth).await.expect("build_app");
let app = picloud::build_app(
pool,
auth,
picloud_shared::MasterKey::from_bytes([0x42u8; 32]),
)
.await
.expect("build_app");
let mut server = TestServer::new(app).expect("TestServer should build");
let resp = server

View File

@@ -57,9 +57,13 @@ async fn boot(pool: PgPool) -> Seeded {
.await
.expect("seed owner");
let app = picloud::build_app(pool.clone(), auth)
.await
.expect("build_app");
let app = picloud::build_app(
pool.clone(),
auth,
picloud_shared::MasterKey::from_bytes([0x42u8; 32]),
)
.await
.expect("build_app");
let server = TestServer::new(app).expect("TestServer");
// Default app id (seeded by migration 0005).

View File

@@ -67,7 +67,13 @@ async fn server_for(pool: PgPool, suffix: &str) -> (TestServer, String) {
.await
.expect("seed admin");
let app = picloud::build_app(pool, auth).await.expect("build_app");
let app = picloud::build_app(
pool,
auth,
picloud_shared::MasterKey::from_bytes([0x42u8; 32]),
)
.await
.expect("build_app");
let mut server = TestServer::new(app).expect("TestServer");
let resp = server
.post("/api/v1/admin/auth/login")