feat(v1.1.1-triggers): trigger CRUD admin endpoints

`/api/v1/admin/apps/{id}/triggers/*` — separate POST endpoints per
kind (kv / dead_letter) so each request validates against the
correct shape. List and DELETE work across both kinds.

Gated on `Capability::AppManageTriggers(app_id)`, which maps onto
`Scope::AppAdmin` (no new scope variants — seven-scope commitment
held) and is granted at the per-app `AppAdmin` role.

Request payloads accept `dispatch_mode` (defaults to `async`) and
retry-override fields. Omitted retry fields fall back to
`TriggerConfig::from_env`, which the binary plumbs into
`TriggersState` so the row is auditable from itself (no lazy
resolution at dispatch time). `registered_by_principal` is taken
from the authenticated principal — design notes §4: "a trigger
execution runs as the principal that registered the trigger".

DELETE loads the trigger first and 404s if its `app_id` doesn't
match the path — prevents a caller with rights on app A from
deleting a trigger via app B's path (bound-key safety net).

In-memory tests cover: app-not-found, member-without-role 403,
default-fallback for retry settings when request omits them,
empty-glob rejection, cross-app delete is treated as not-found.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-01 21:52:51 +02:00
parent 545d863199
commit 2e92691ee1
3 changed files with 771 additions and 7 deletions

View File

@@ -12,13 +12,14 @@ 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, migrations, require_authenticated,
route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState,
AppRepository, AppsState, AuthState, AuthzRepo, KvServiceImpl, PostgresAdminSessionRepository,
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
PostgresAppMembersRepository, PostgresAppRepository, PostgresExecutionLogRepository,
PostgresExecutionLogSink, PostgresKvRepo, PostgresRouteRepository, PostgresScriptRepository,
RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
route_admin_router, triggers_router, AdminSessionRepository, AdminState, AdminUserRepository,
AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository,
AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo, KvServiceImpl,
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo,
PostgresRouteRepository, PostgresScriptRepository, PostgresTriggerRepo, RepoResolver,
RouteAdminState, RouteRepository, SandboxCeiling, TriggerConfig, TriggerRepo, TriggersState,
};
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
use picloud_orchestrator_core::{
@@ -109,6 +110,12 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
let services = Services::new(kv, Arc::new(NoopDeadLetterService), events);
let engine = Arc::new(Engine::new(Limits::default(), services));
// Trigger repo + config, shared between the admin endpoint and the
// dispatcher (commit 5). Read defaults from env so operators can
// tune retry / depth without rebuilding the binary.
let trigger_repo: Arc<dyn TriggerRepo> = Arc::new(PostgresTriggerRepo::new(pool));
let trigger_config = TriggerConfig::from_env();
// Compile the routes table once at startup; admin writes refresh it.
let route_table = Arc::new(RouteTable::new());
let initial = route_repo.list_all().await?;
@@ -163,6 +170,12 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
app_domains: app_domain_table.clone(),
routes: route_table,
};
let triggers_state = TriggersState {
triggers: trigger_repo,
apps: apps_repo.clone(),
authz: authz.clone(),
config: trigger_config,
};
let apps_state = AppsState {
apps: apps_repo,
domains: domains_repo,
@@ -204,6 +217,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
.merge(apps_router(apps_state))
.merge(app_members_router(app_members_state))
.merge(api_keys_router(api_keys_state))
.merge(triggers_router(triggers_state))
.layer(from_fn_with_state(
auth_state.clone(),
require_authenticated,