uptrakit

Shared Surface Security

Shared Surface Security

Overview

Shared surfaces let plugins and connected services project UI capabilities through the controller. Security is enforced by a fail-closed contract admission model plus per-request authorization and transport controls.

Authorization model

Authorization is evaluated at two levels, both carried on the wire as required_action: Option<String> — a canonical resource:verb catalog action string:

  • descriptor-level: SurfaceDescriptor.required_action
  • interaction-level: InteractionDescriptor.required_action

Each value is parsed to a catalog Action by SurfaceProxy at registration admission, not at request time. An unparseable value rejects the whole registration (SurfaceProviderRejectionCode::SchemaOrLimitFailure) — every surface and interaction the provider registered in that call is absent, not just the offending one. A parseable-but-currently-unregistered action is admitted; whether it grants access is decided later, per request, by AccessEngine. The registry stores the parsed Action index-aligned with the normalized registration, so web-api never re-parses the string at request time.

Enforcement points:

  • GET /api/v1/surfaces and GET /api/v1/surfaces/{surface_id}/providers — authenticated-only; no static permission variant exists for surface listing and inventing one is out of scope, so results are visibility-filtered per descriptor instead
  • GET /api/v1/surfaces/{surface_id} — descriptor action check
  • GET|POST|PUT|DELETE /api/v1/surfaces/{surface_id}/interactions/{interaction_id} and the .../{interaction_id}/{item_id} variants — descriptor and interaction action checks, on every method

Every method-mapped interaction route resolves in the same order: unknown surface/interaction is 404; then the descriptor action, then the interaction action (403 on deny, 500 if AccessEngine's authority is Unavailable — fail-closed); only then a method mismatch (405, with an Allow header). So the full resolution order is 404 → 403/500 → 405. The action check runs before the method-mismatch check specifically so an unauthorized caller cannot probe an interaction's registered method set by comparing 403 against 405 across methods. See Shared Surface API for the full resolution order and the two distinct Allow-header shapes.

Both invoke paths — HTTP and provider-origin — enforce the descriptor action check in addition to the interaction action check (see Provider-origin invocation; history in ADR-0040).

Read and invoke enforce the dynamic descriptor/interaction actions in-handler via enforce_required_action, which runs the resolved Action through AccessEngine: None action allows; Ready authority + Allow decision allows; Ready + deny returns 403 and increments the uptrakit_access_denies_total counter; Unavailable authority returns 500 (fail-closed, never silently permissive). This is a documented exception to the platform's typed-action-extractor rule, because the required action is runtime descriptor data no static extractor can carry; the OpenAPI operations advertise the runtime-valued requirement via the boolean x-action-dynamic: true extension, paired with an authenticated-only security declaration — the enforced requirement itself lives in the registered descriptor/interaction, not in the spec. See Authentication and Authorization for how this exception class is distinguished from the platform's other two documented extractor exceptions.

Denied-audit entries record the failing value under the required_action key in details_json (renamed from required_permission); the reason_code literal missing_required_permission and the permission_scope key are deliberately unchanged.

Frontend filtering is convenience only; server checks are authoritative. The SPA's client-side filter (filterSurfacesByAction()) compares action strings against the current user's real grants, so this is in sync with server-side enforcement via AccessEngine.

GET query strings and sensitive data

DataLoad interactions are dispatched over GET, so their params travel in the URL query string rather than a request body. Query strings are visible in server access logs, browser history, and (for cross-origin navigations) the Referer header — a materially different exposure surface than a JSON body.

This is an accepted trade because DataLoad params are never allowed to carry secrets: admission validation rejects any DataLoad interaction that declares a non-empty sensitive_fields list, with the error data-load interaction {id} in surface {surface_id} must not declare sensitive_fields (GET params travel in query strings). A provider that legitimately needs to pass a sensitive value into a load path must model it as a non-DataLoad interaction kind instead (which keeps POST/PUT/DELETE semantics and can use encrypted_sensitive_params).

Failure mode for providers that violate this rule (e.g. an older or out-of-repo provider build predating this admission check): registration of the offending surface is rejected outright at admission time. The surface is simply absent from GET /api/v1/surfaces and every interaction on it is unreachable — there is no partial registration and no runtime error path that a caller could trigger by invoking the interaction. The provider's connection/registration logs show the rejection reason; end users see no trace of the surface at all.

Provider-origin invocation

Provider-origin (service-initiated) calls carry no user; they are gated by tenant scope plus the provider-permission gate: provider-origin calls are denied when the surface descriptor or the interaction carries required_action, unless the interaction sets provider_invocable — symmetric with the HTTP path's two-layer enforcement.

provider_invocable = true means any same-tenant provider with the UiSurfaces capability may invoke the interaction, subject only to tenant scope and the standard idempotency/timeout/rate controls. It is a deliberate privilege expansion, accepted because tenant services are co-trusted (enrolled by the tenant admin), writes are tenant-scoped and recoverable, and every provider-origin invocation is audit-attributed to the calling service.

Registration admission rejects the flag on Service-kind interactions that carry required_action or sit on a surface whose descriptor carries one (Plugin/BuiltIn-owned interactions only may combine the flag with a gate); per-caller narrowing is deliberately outside the flag — a future optional allowlist field composes with it.

The invoke-time gate itself is provider-kind-agnostic by design: it keys on CallerOrigin::Provider, not ProviderKind. Kind-based permissiveness (Plugin/BuiltIn may combine provider_invocable with a gate) is enforced — and test-pinned — at registration admission only.

A service therefore cannot provider-invoke interactions on its own descriptor-gated surfaces — no service-side opt-in exists for that shape; such surfaces are user/HTTP-driven only.

Handlers of flagged interactions must not treat provider origin as privileged beyond tenant membership.

Effective-enablement gate

Every tenant-facing surfaces leg — list, providers, read, invoke, and provider-origin invocation — is gated on the owning plugin's effective enablement (boot ∧ live, ADR-0033) via a required SurfaceProviderVisibility filter on the registry's resolution methods. The posture is fail-closed: a Plugin-kind provider that resolves to no compiled-in descriptor is never visible, and SurfaceProxy defaults to denying all plugin providers unless the production filter is wired in. A hidden surface's response is byte-identical to an unknown surface's (404, no existence side-channel), for every permission tier — there is no admin override on the surfaces legs.

Response caching

Surface GET responses set Cache-Control: private, no-store; results are per-tenant and per-permission data that must not be cached by shared caches or bfcache.

Registration admission controls

Service and plugin providers are admitted through SurfaceRegistry with strict validation:

  • framework generation compatibility (supported_generation)
  • required capability coverage
  • slot ID validation against central slot registry
  • provider-kind/transport compatibility
  • provider-id namespace per source kind (service. / builtin. / neither for plugins) — fail-closed (ADR-0034)
  • tenant-binding correctness for authenticated service context
  • allowlist checks for controller queries and SSE topics
  • contract shape and depth limits
  • payload and interaction count limits

Invalid registrations are rejected with structured rejection reasons.

Targeting and tenancy controls

Targeted surfaces require explicit target_provider_id at invocation time. Provider resolution is tenant-aware; cross-tenant providers are excluded from availability and dispatch.

For service providers:

  • tenant services must register tenant-scoped bindings matching authenticated tenant
  • system services must register global scope with no tenant binding

Yielded-provider eviction

validate_contract_collisions rejects a second provider registering the same surface_id with Universal targeting in the same effective scope — the fail-closed default that prevents two providers from silently racing for the same surface. When an external service connects and an embedded counterpart with the same app name yields to it, the controller unregisters the yielded service's surface provider and fails its in-flight requests (evict_yielded_service_surfaces) synchronously with the yield-flag flip, before the newly-connecting external service's own registration is processed. This closes the window where the external provider's equivalent Universal registration could otherwise be rejected as a collision against a provider that has already stopped serving traffic, without ever admitting two live providers for the same surface at once. See ADR-0042.

Sensitive parameter handling

Sensitive interaction values are not sent in plaintext params. Clients send encrypted_sensitive_params (ECIES P-256 metadata + ciphertext payload).

Controller behavior:

  • validates presence/shape requirements
  • forwards ciphertext opaquely to provider
  • never decrypts sensitive payloads

Provider behavior:

  • publishes encryption metadata in provider info
  • decrypts payload locally using provider private key

Action execution controls

Invocation path is mediated by SurfaceProxy:

  • idempotency key handling (duplicate_request protection)
  • per-request timeout handling
  • provider disconnect and unavailability handling
  • typed error-code mapping for caller-safe failure semantics
  • in-flight budget/idempotency cancellation-safety: if a caller disconnects mid-request, the proxy releases the provider/tenant in-flight budget and the idempotency reservation on future-drop (RAII guards) plus a deadline-keyed backstop sweep, so a disconnecting client cannot permanently exhaust a provider's or tenant's in-flight budget or wedge an idempotency key

Capability gating

UI/surface runtime participation is gated on protocol capability negotiation. Incompatible providers are excluded from the active surface catalog; their registrations are rejected at admission time rather than silently degrading runtime behavior.

Trust boundaries

  • Browser never talks directly to services for surface actions.
  • Service-to-controller calls are authenticated over mTLS WebSocket.
  • Controller mediates all provider selection and dispatch.
  • NATS is not used for session-targeted surface action payload delivery.

Key files

FilePurpose
crates/shared/surfaces/src/Shared surface contract and validation policy types
crates/shared/wire/src/wire_validate_impls.rsWire-level payload validation
crates/ui/web-api/src/surface_registry.rsRegistration admission and tenant/provider indexing
crates/ui/surface-proxy/src/proxy.rs (+ proxy/{validation,resolution,idempotency,bookkeeping,dispatch}.rs)Invocation coordination (ADR-0040 gate), validation, resolution, idempotency, in-flight bookkeeping, transport dispatch
crates/ui/web-api/src/routes/surfaces.rsAuthz enforcement and API error mapping
crates/ui/web-api/src/routes/service_ws/handler/mod.rsService message handling for surface registration and actions
crates/ui/web-api/src/routes/service_ws/handler/surface_eviction.rsEviction of yielded services' surface providers on external service connect

See also