diff --git a/adapter-http-legacy-admin/src/main/java/network/crypta/clients/http/PlatformApiToadlet.java b/adapter-http-legacy-admin/src/main/java/network/crypta/clients/http/PlatformApiToadlet.java index 962768515e..ea0ddf67d2 100644 --- a/adapter-http-legacy-admin/src/main/java/network/crypta/clients/http/PlatformApiToadlet.java +++ b/adapter-http-legacy-admin/src/main/java/network/crypta/clients/http/PlatformApiToadlet.java @@ -8,13 +8,16 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import network.crypta.platform.api.PlatformApiException; import network.crypta.platform.api.PlatformApiPaths; +import network.crypta.platform.api.PlatformApiPrincipal; import network.crypta.platform.api.PlatformApiRequest; import network.crypta.platform.api.PlatformApiResponse; import network.crypta.platform.api.PlatformApiRouter; import network.crypta.platform.appcatalog.AppCatalogManager; import network.crypta.platform.apphost.AppHost; +import network.crypta.platform.apphost.AppTokenPrincipal; import network.crypta.runtime.spi.RuntimePorts; import network.crypta.support.MultiValueTable; import network.crypta.support.URLDecoder; @@ -29,8 +32,9 @@ * *

This toadlet keeps all transport-neutral routing and JSON construction inside {@code * :platform-api}. Its responsibility is limited to enforcing the existing full-access expectation - * for admin-facing routes, translating legacy HTTP request state into a {@link PlatformApiRequest}, - * and writing the resulting JSON back through the legacy HTTP shell. + * for host/operator routes, authenticating AppHost launch-token headers when present, translating + * legacy HTTP request state into a {@link PlatformApiRequest}, and writing the resulting JSON back + * through the legacy HTTP shell. */ @SuppressWarnings("unused") public final class PlatformApiToadlet extends Toadlet { @@ -50,6 +54,9 @@ public final class PlatformApiToadlet extends Toadlet { private static final String URL_ENCODED_CONTENT_TYPE = "application/x-www-form-urlencoded"; + private static final String APP_TOKEN_HEADER = "x-crypta-app-token"; + private static final String AUTHORIZATION_HEADER = "authorization"; + private static final String BEARER_PREFIX = "bearer"; private static final String DELETE_METHOD = "DELETE"; private static final String ALERTS_SEGMENT = "alerts"; private static final String APP_CATALOGS_SEGMENT = "app-catalogs"; @@ -67,6 +74,9 @@ public final class PlatformApiToadlet extends Toadlet { */ private final PlatformApiRouter router; + /** Optional AppHost used to authenticate process-originated app launch tokens. */ + private final AppHost appHost; + /** * Creates a platform API toadlet backed by the supplied runtime ports. * @@ -74,8 +84,8 @@ public final class PlatformApiToadlet extends Toadlet { */ public PlatformApiToadlet(RuntimePorts runtimePorts) { this( - new PlatformApiRouter( - runtimePorts, null, null, LegacyAdminUsageRecorder.defaultRecorder())); + new PlatformApiRouter(runtimePorts, null, null, LegacyAdminUsageRecorder.defaultRecorder()), + null); } /** @@ -87,7 +97,8 @@ public PlatformApiToadlet(RuntimePorts runtimePorts) { public PlatformApiToadlet(RuntimePorts runtimePorts, AppHost appHost) { this( new PlatformApiRouter( - runtimePorts, appHost, null, LegacyAdminUsageRecorder.defaultRecorder())); + runtimePorts, appHost, null, LegacyAdminUsageRecorder.defaultRecorder()), + appHost); } /** @@ -101,7 +112,8 @@ public PlatformApiToadlet( RuntimePorts runtimePorts, AppHost appHost, AppCatalogManager appCatalogManager) { this( new PlatformApiRouter( - runtimePorts, appHost, appCatalogManager, LegacyAdminUsageRecorder.defaultRecorder())); + runtimePorts, appHost, appCatalogManager, LegacyAdminUsageRecorder.defaultRecorder()), + appHost); } /** @@ -114,8 +126,13 @@ public PlatformApiToadlet( * @param router transport-neutral router that handles request validation and response generation */ PlatformApiToadlet(PlatformApiRouter router) { + this(router, null); + } + + PlatformApiToadlet(PlatformApiRouter router, AppHost appHost) { super(); this.router = Objects.requireNonNull(router, "router"); + this.appHost = appHost; } @Override @@ -269,11 +286,13 @@ private void writePlatformApiResponse( /** * Routes a request through the Platform API and writes either a full or header-only reply. * - *

Legacy HTTP integration keeps full-access enforcement at the bridge boundary. Mutating - * requests then pass through the legacy form-password check before the bridge converts them into - * a transport-neutral {@link PlatformApiRequest} and delegates to the router. Unexpected runtime - * failures are converted into a structured {@code 500} response so callers keep the Platform API - * JSON contract even when the bridge logs the underlying error. + *

Legacy HTTP integration keeps host/operator full-access enforcement at the bridge boundary. + * Mutating host/operator requests then pass through the legacy form-password check before the + * bridge converts them into a transport-neutral {@link PlatformApiRequest}. App-token requests + * skip that host/operator form guard and carry a token-free app principal into the router, where + * manifest capability enforcement happens centrally. Unexpected runtime failures are converted + * into a structured {@code 500} response so callers keep the Platform API JSON contract even when + * the bridge logs the underlying error. * * @param method HTTP method name forwarded into the Platform API router * @param uri request target supplied by the legacy HTTP shell @@ -288,17 +307,8 @@ private void writePlatformApiResponse( String method, URI uri, HTTPRequest request, ToadletContext ctx, boolean includeBody) throws ToadletContextClosedException, IOException { PlatformApiResponse response; - if (!ctx.isAllowedFullAccess()) { - response = PlatformApiResponse.error(403, "forbidden", "Full access is required."); - writeJsonReply(ctx, response, includeBody); - return; - } - if (!authorizeMutationRequest(method, uri, request, ctx)) { - return; - } - try { - response = router.route(toPlatformApiRequest(method, uri, request)); + response = resolveAndRoutePlatformApiRequest(method, uri, request, ctx); } catch (URLEncodedFormatException _) { response = PlatformApiResponse.error( @@ -310,9 +320,48 @@ private void writePlatformApiResponse( response = PlatformApiResponse.error(500, "internal_error", "Unexpected platform API failure."); } + if (response == null) { + return; + } writeJsonReply(ctx, response, includeBody); } + /** + * Resolves the request principal, enforces bridge-local host guards, and routes the API request. + * + *

The caller wraps this method in the Platform API error guard so unexpected failures in token + * authentication, host access checks, form-password checks, request conversion, or router + * dispatch all produce the same structured JSON error contract. + * + * @param method HTTP method name forwarded into the Platform API router + * @param uri request target supplied by the legacy HTTP shell + * @param request decoded legacy HTTP request wrapper + * @param ctx current toadlet context used for access checks + * @return Platform API response to write, or {@code null} when a bridge-local guard already wrote + * the response + * @throws ToadletContextClosedException if the client disconnects while a guard response is sent + * @throws IOException if the legacy HTTP shell fails while writing a guard response + * @throws URLEncodedFormatException if the request path contains malformed percent-encoding + */ + private PlatformApiResponse resolveAndRoutePlatformApiRequest( + String method, URI uri, HTTPRequest request, ToadletContext ctx) + throws ToadletContextClosedException, IOException, URLEncodedFormatException { + PrincipalResolution principalResolution = resolvePrincipal(request); + if (principalResolution.failureResponse() != null) { + return principalResolution.failureResponse(); + } + PlatformApiPrincipal principal = principalResolution.principal(); + + if (!principal.isApp() && !ctx.isAllowedFullAccess()) { + return PlatformApiResponse.error(403, "forbidden", "Full access is required."); + } + if (!principal.isApp() && !authorizeMutationRequest(method, uri, request, ctx)) { + return null; + } + + return router.route(toPlatformApiRequest(method, uri, request, principal)); + } + /** * Returns whether the current request targets one of the mutating app-management routes. * @@ -603,7 +652,8 @@ private boolean authorizeMutationRequest( * @return immutable Platform API request built from the method, relative path, and query values * @throws URLEncodedFormatException if the request path contains malformed percent-encoding */ - private PlatformApiRequest toPlatformApiRequest(String method, URI uri, HTTPRequest request) + private PlatformApiRequest toPlatformApiRequest( + String method, URI uri, HTTPRequest request, PlatformApiPrincipal principal) throws URLEncodedFormatException { LinkedHashMap> queryParameters = LinkedHashMap.newLinkedHashMap(request.getParameterNames().size()); @@ -614,9 +664,109 @@ private PlatformApiRequest toPlatformApiRequest(String method, URI uri, HTTPRequ queryParameters.put(parameterName, List.of(request.getMultipleParam(parameterName))); } copyBodyParametersIfPresent(request, queryParameters); - return new PlatformApiRequest(method, relativeApiPath(requestPath(uri)), queryParameters); + return new PlatformApiRequest( + method, relativeApiPath(requestPath(uri)), queryParameters, principal); + } + + private PrincipalResolution resolvePrincipal(HTTPRequest request) { + String directToken = directAppTokenFromHeader(request); + if (directToken != null) { + return authenticateExplicitAppToken(directToken); + } + + String bearerToken = bearerTokenFromAuthorization(request); + if (bearerToken == null) { + return hostOperatorPrincipal(); + } + return authenticateBearerTokenOrHostOperator(bearerToken); } + private static PrincipalResolution unauthenticatedAppToken(String message) { + return new PrincipalResolution( + null, PlatformApiResponse.error(401, "invalid_app_token", message)); + } + + private static PrincipalResolution hostOperatorPrincipal() { + return new PrincipalResolution(PlatformApiPrincipal.hostOperator(), null); + } + + /** + * Authenticates a token from the app-specific header. + * + *

{@code X-Crypta-App-Token} is an explicit app-token assertion, so blank, stale, unknown, or + * unavailable token validation returns a structured authentication failure. + * + * @param token token value after header normalization + * @return app principal for a live token, or an authentication failure response + */ + private PrincipalResolution authenticateExplicitAppToken(String token) { + if (appHost == null) { + return unauthenticatedAppToken("App token authentication is unavailable."); + } + Optional appPrincipal = appHost.authenticateLaunchToken(token); + if (appPrincipal.isEmpty()) { + return unauthenticatedAppToken("Invalid app token."); + } + return appTokenPrincipal(appPrincipal.get()); + } + + /** + * Authenticates a Bearer token when it matches a live app token. + * + *

{@code Authorization: Bearer} is intentionally opportunistic because the same header is also + * used by reverse proxies and shared HTTP client configurations for unrelated host/operator + * credentials. A matching live AppHost token becomes an app principal. A non-matching Bearer + * value keeps the existing host/operator path instead of converting the request into a failed + * app-token attempt. Lookup failures are allowed to propagate to the guarded Platform API error + * path so a token-bearing request cannot silently widen into a host/operator principal when + * AppHost state is unavailable. + * + * @param token bearer token value after header normalization + * @return app principal for a live token, otherwise the host/operator principal + */ + private PrincipalResolution authenticateBearerTokenOrHostOperator(String token) { + if (appHost == null) { + return hostOperatorPrincipal(); + } + Optional appPrincipal = appHost.authenticateLaunchToken(token); + if (appPrincipal.isEmpty()) { + return hostOperatorPrincipal(); + } + return appTokenPrincipal(appPrincipal.get()); + } + + private static PrincipalResolution appTokenPrincipal(AppTokenPrincipal principal) { + return new PrincipalResolution( + PlatformApiPrincipal.appToken(principal.appId(), principal.permissions()), null); + } + + private static String directAppTokenFromHeader(HTTPRequest request) { + String directToken = request.getHeader(APP_TOKEN_HEADER); + return directToken == null ? null : directToken.trim(); + } + + private static String bearerTokenFromAuthorization(HTTPRequest request) { + String authorization = request.getHeader(AUTHORIZATION_HEADER); + if (authorization == null) { + return null; + } + String trimmed = authorization.trim(); + if (trimmed.length() < BEARER_PREFIX.length() + || !trimmed.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) { + return null; + } + if (trimmed.length() == BEARER_PREFIX.length()) { + return ""; + } + if (!Character.isWhitespace(trimmed.charAt(BEARER_PREFIX.length()))) { + return null; + } + return trimmed.substring(BEARER_PREFIX.length()).trim(); + } + + private record PrincipalResolution( + PlatformApiPrincipal principal, PlatformApiResponse failureResponse) {} + /** * Copies body-backed form values into the query-like parameter map when present. * diff --git a/docs/SECURITY.md b/docs/SECURITY.md index c742d47d84..f26c2757b5 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -77,11 +77,24 @@ data/cache/run directories, and a per-launch `CRYPTAD_APP_TOKEN` for app-origina authentication. It does not currently provide containers, WASM isolation, seccomp, chroot, jails, Windows Job Object restrictions, network isolation, or browser-scoped app sessions. +App-originated Platform API calls authenticate with the launch token in `X-Crypta-App-Token`. +`Authorization: Bearer` is accepted only when the Bearer value matches a live app token; unrelated +Bearer credentials continue through the host/operator path so reverse proxies and shared clients do +not accidentally convert local management requests into failed app-token attempts. Valid app +principals are denied by default unless the route is covered by manifest-declared capabilities such +as `queue.read`, `queue.write`, or `content.insert`. Invalid or stale `X-Crypta-App-Token` values +fail authentication, and missing capabilities fail authorization without echoing the token. + Runtime status and process-log Platform API responses must remain token-free and path-free. Log tail responses redact the current launch token and obvious `CRYPTAD_APP_TOKEN=...` text before returning app output to the Web Shell. This is defense in depth for operator visibility; it is not a general secret scanner for arbitrary app output. +App-originated allowed and denied Platform API decisions are recorded in a bounded process-local +audit log. Audit events keep route family, action, required capabilities, decision, status, and a +short reason code. They must not include raw launch tokens, query strings, request bodies, form +passwords, or filesystem paths. + First-party static app UIs can fetch `/apps/{appId}/.well-known/cryptad-bootstrap.json` to discover local route roots and the existing local-admin form password used for mutating Platform API calls. That bootstrap is host/operator scoped. It must not contain `CRYPTAD_APP_TOKEN`, AppHost launch @@ -93,5 +106,7 @@ Treat a bypass that serves host files, follows symlink escapes, executes JavaScr UI has JavaScript disabled, exposes AppHost launch tokens to browser code, or allows bundled UI to exfiltrate operator-entered data off-node as security-relevant. -For the detailed runtime boundary, exposed environment variables, restart policy semantics, and -remaining limitations, see [apphost-runtime-hardening.md](apphost-runtime-hardening.md). +For the detailed runtime boundary, exposed environment variables, restart policy semantics, +permission matrix, audit model, and remaining limitations, see +[apphost-runtime-hardening.md](apphost-runtime-hardening.md) and +[app-permissions-and-audit.md](app-permissions-and-audit.md). diff --git a/docs/app-catalogs.md b/docs/app-catalogs.md index d5ca14a349..fbe5d1f5f0 100644 --- a/docs/app-catalogs.md +++ b/docs/app-catalogs.md @@ -116,5 +116,6 @@ and security boundary. ## Future work -This PR does not add permission enforcement, container or WASM sandboxing, public app-store -governance, or background app update scheduling. +Manifest permissions are enforced for app-process Platform API calls as described in +[app-permissions-and-audit.md](app-permissions-and-audit.md). Container or WASM sandboxing, public +app-store governance, and background app update scheduling remain future work. diff --git a/docs/app-owned-ui.md b/docs/app-owned-ui.md index a163c259d6..3e15519439 100644 --- a/docs/app-owned-ui.md +++ b/docs/app-owned-ui.md @@ -86,10 +86,11 @@ App-owned UI uses stable URLs across reinstall and update operations. Responses with non-public no-cache headers instead of the legacy admin adapter's long-lived static cache policy. -The route remains same-origin with the local admin UI and Platform API. Permission enforcement, -stronger isolation, and audit logging belong to later platform work. Until stronger browser -isolation exists, install static UI bundles only from sources trusted to run JavaScript in the local -admin origin. +The route remains same-origin with the local admin UI and Platform API. Static browser UI does not +receive `CRYPTAD_APP_TOKEN` and cannot authenticate as the app process in this model. App +permission enforcement and audit apply to process-originated Platform API calls that present the +launch token. Stronger browser isolation remains later platform work. Until that exists, install +static UI bundles only from sources trusted to run JavaScript in the local admin origin. ## First-party app bootstrap @@ -163,6 +164,8 @@ Runtime hardening APIs are separate from the app summary: ```text GET /api/v1/apps/{appId}/runtime GET /api/v1/apps/{appId}/logs?maxBytes=65536 +GET /api/v1/apps/{appId}/permissions +GET /api/v1/apps/{appId}/audit ``` The runtime endpoint reports process state, PID, start time, last exit metadata, restart attempt @@ -173,6 +176,11 @@ installed/data/cache/run filesystem paths. The Web Shell uses those endpoints for operator visibility. Static app UIs must not treat the runtime endpoint as proof of app-level health; it is process status only. +The permissions and audit endpoints expose manifest-declared permissions, recent app-originated +Platform API decisions, and retained denied-call counts. Audit entries are bounded and +process-local, and they omit tokens, query strings, request bodies, form passwords, and filesystem +paths. + ## Examples Static UI bundle: diff --git a/docs/app-permissions-and-audit.md b/docs/app-permissions-and-audit.md new file mode 100644 index 0000000000..5f83468224 --- /dev/null +++ b/docs/app-permissions-and-audit.md @@ -0,0 +1,118 @@ +# App permissions and audit + +This document describes how Cryptad authorizes app-originated Platform API requests and records +recent app decisions. + +## Scope + +The app permission boundary applies to local AppHost child processes that authenticate with their +current launch token. It does not add containers, WASM isolation, seccomp, chroot, browser-scoped +app sessions, FCP changes, wire-protocol changes, or persistent compliance-grade audit storage. + +Host/operator Web Shell requests keep the existing local-admin model. They do not need app +permissions, and they are not recorded in the app audit log. + +## Process token authentication + +Every AppHost launch receives a fresh opaque `CRYPTAD_APP_TOKEN` in the child process environment. +The token is valid only while that specific app run is currently tracked as live. Blank, unknown, +stopped, or stale tokens do not authenticate. Restarting an app creates a new token; the old token +stops working. + +An app process can present the token to Platform API v1 with either header: + +```text +X-Crypta-App-Token: +Authorization: Bearer +``` + +`X-Crypta-App-Token` is the explicit app-token header: blank, unknown, stopped, or stale values +fail authentication with `401 invalid_app_token`. `Authorization: Bearer` is opportunistic for +clients that already use Bearer token plumbing. If the Bearer value matches a live app token, the +request becomes an app principal; otherwise it remains a normal host/operator request so unrelated +Bearer credentials from proxies or shared client configuration do not break existing management +flows. + +Tokens are not read from query parameters. Static app UI bootstrap JSON, Web Shell bootstrap JSON, +app summaries, runtime status, process-log responses, audit entries, diagnostic strings, and +`toString()` output must not expose raw launch tokens. + +## App principals + +When a token authenticates, the Platform API receives a token-free app principal: + +- app id; +- manifest-declared permissions; +- authentication source `APP_TOKEN`. + +The principal does not carry the raw token. The permission list is immutable and sorted before the +router checks it. + +## Capability checks + +App principals are denied by default. A request must match the central route-to-capability matrix, +and the app principal must include every required capability. A valid app token without the +required capability receives `403 Forbidden`. An invalid or stale app token receives +`401 Unauthorized`. + +The current capabilities are intentionally conservative: + +| Capability | Example covered routes | +| --- | --- | +| `node.read` | `GET /api/v1/node/**` | +| `connectivity.read` | `GET /api/v1/connectivity` | +| `queue.read` | `GET /api/v1/queue/**` | +| `queue.write` | queue download creation, request mutation, cleanup | +| `content.insert` | local file/directory insert routes, together with `queue.write` | +| `peers.read` | `GET /api/v1/peers/**` | +| `peers.write` | peer add, settings, note, removal | +| `config.read` | `GET /api/v1/config` | +| `config.write` | config overrides and persist | +| `security.read` | `GET /api/v1/security-levels/**` | +| `security.write` | network and physical threat-level mutations | +| `updates.read` | `GET /api/v1/updates/**` | +| `updates.write` | core update download trigger | +| `wizard.read` | `GET /api/v1/wizard/**` | +| `wizard.write` | first-time wizard apply | +| `alerts.read` | `GET /api/v1/alerts` | +| `alerts.write` | alert dismiss | +| `diagnostics.read` | `GET /api/v1/diagnostics` | +| `apps.read` | app inventory, runtime, logs, permissions, audit reads | +| `apps.manage` | app install, start, stop, update, uninstall | +| `catalogs.read` | catalog and catalog-app reads | +| `catalogs.manage` | catalog add/remove/refresh and catalog app install/update | + +## Audit trail + +The Platform API records app-originated allowed and denied authorization decisions in a bounded +process-local audit log. The default bound is the most recent 512 events per router instance. When +the log is full, the oldest entries are dropped. + +Each event records: + +- timestamp; +- app id; +- request method; +- endpoint family; +- route/action label; +- required capability names; +- decision; +- HTTP status; +- short reason code. + +Audit entries do not record raw launch tokens, full query strings, request bodies, form passwords, +local filesystem paths, or large payloads. The log is useful for operator visibility and debugging; +it is not durable evidence storage. + +## Operator surfaces + +The Apps API exposes declared permissions and recent audit data: + +```text +GET /api/v1/apps/{appId}/permissions +GET /api/v1/apps/{appId}/audit +``` + +Installed app summaries also include a small audit object and a retained denied-count snapshot. +The Web Shell displays declared permissions, recent app-originated audit events, and denied-call +counts on installed app cards. diff --git a/docs/apphost-runtime-hardening.md b/docs/apphost-runtime-hardening.md index ea26329dce..0e48cf15bb 100644 --- a/docs/apphost-runtime-hardening.md +++ b/docs/apphost-runtime-hardening.md @@ -42,9 +42,17 @@ CRYPTAD_APP_UI_MODE CRYPTAD_APP_UI_ENTRY # only when the manifest declares a UI entry ``` -`CRYPTAD_APP_TOKEN` is for app-originated Platform API authentication. It is injected only into the -child process environment. It is not exposed to static browser UI, Web Shell bootstrap JSON, app -API summaries, runtime status JSON, or process-log tail responses. +`CRYPTAD_APP_TOKEN` is for process-originated Platform API authentication. It is injected only into +the child process environment. App processes should present it with `X-Crypta-App-Token`; the bridge +also accepts `Authorization: Bearer` when the Bearer value matches a live app token. Unrelated +Bearer credentials stay on the host/operator path. The token is not exposed to static browser UI, +Web Shell bootstrap JSON, app API summaries, runtime status JSON, process-log tail responses, or app +audit entries. + +The token authenticates only the currently running app instance. A stopped app or a previous run's +token does not authenticate. The verified app principal contains the app id and manifest +permissions, never the token itself. See [app-permissions-and-audit.md](app-permissions-and-audit.md) +for the capability matrix and audit surface. AppHost does not inject daemon datastore paths, trusted-key files, catalog roots, signing material, or the daemon's current working directory. The child process working directory is the installed app @@ -88,6 +96,10 @@ Process-log tailing is bounded. The default is small, and AppHost clamps oversiz hard maximum. Missing logs return a stable unavailable snapshot. Log responses include text and metadata only; they do not include the runtime log path. +App-originated Platform API decisions are recorded separately in a bounded process-local audit log. +The Apps API exposes recent entries through `GET /api/v1/apps/{appId}/audit`; those entries omit +tokens, query strings, request bodies, form passwords, and filesystem paths. + Before returning process-log text, AppHost redacts: - the exact current launch token when the app is running; diff --git a/docs/phase-3-platform-primacy-closeout.md b/docs/phase-3-platform-primacy-closeout.md index 0b7171d458..185948d7df 100644 --- a/docs/phase-3-platform-primacy-closeout.md +++ b/docs/phase-3-platform-primacy-closeout.md @@ -156,7 +156,8 @@ Phase 4 candidates were plans, not PR-194 implementation scope. Current status: - App-owned static UI routes have landed for installed static bundles; stronger isolation remains future work. - Broader first-party app catalog remains future work. -- Better app permission enforcement and audit surfaces remain future work. +- App permission enforcement and app-origin audit landed after this Phase 3 closeout; see + [app-permissions-and-audit.md](app-permissions-and-audit.md). - Legacy admin and browse retirement plan. - Portable full-node Hyphanet baseline artifacts for macOS and Windows. - Lightweight performance and regression smoke coverage now lives under diff --git a/docs/platform-api-surface.md b/docs/platform-api-surface.md index 727eea3b8b..7351051f65 100644 --- a/docs/platform-api-surface.md +++ b/docs/platform-api-surface.md @@ -44,14 +44,23 @@ Current error handling uses HTTP-style status codes: | Status | Current use | | --- | --- | | `400 Bad Request` | Malformed paths, malformed percent-encoding, or invalid query/form values. | +| `401 Unauthorized` | App-token authentication failed because a supplied launch token is invalid, blank, stale, or unavailable. | | `403 Forbidden` | The legacy bridge rejects a caller without full access. | | `404 Not Found` | Unknown routes or missing resources. | | `405 Method Not Allowed` | A known route was called with the wrong method; the response includes `Allow`. | | `409 Conflict` | Stateful conflicts, confirmation requirements, or temporarily unavailable control paths. | | `500 Internal Server Error` | Unexpected failures after routing. | -Mutating routes still rely on the legacy admin bridge for full-access checks and the form-password -guard. Current Web Shell mutations submit URL-encoded form data. +Host/operator mutating routes still rely on the legacy admin bridge for full-access checks and the +form-password guard. Current Web Shell mutations submit URL-encoded form data. + +App processes can authenticate with the per-launch `CRYPTAD_APP_TOKEN` injected by AppHost. The +legacy HTTP bridge accepts the token in `X-Crypta-App-Token`; it also accepts Bearer credentials in +the `Authorization` header only when the Bearer value verifies as a live app token. Query parameters +are ignored for token authentication, and unrelated Bearer credentials remain host/operator +requests. Authenticated app requests are checked against manifest-declared capabilities before +routing and are denied by default. See [app-permissions-and-audit.md](app-permissions-and-audit.md) +for the matrix and audit model. ## Endpoint families @@ -70,7 +79,7 @@ parameter; check the handler and tests when adding or changing a specific contra | Wizard/welcome | `GET /api/v1/wizard/first-time` exposes the detached first-time setup snapshot. `POST /api/v1/wizard/first-time/apply` submits the shell-native onboarding/reset model. There is no separate `/welcome` Platform API family in Phase 3; welcome-page fallback behavior remains on the legacy/admin side. | | Alerts | `GET /api/v1/alerts` lists current alerts. `POST /api/v1/alerts/{alertId}/dismiss` dismisses one alert by detached identifier. | | Diagnostics | `GET /api/v1/diagnostics` exposes the ordered diagnostic snapshot and plain-text export. When served through the legacy HTTP bridge, it also includes process-local `legacyAdmin.surfaces[]` counters for legacy admin retirement planning. | -| Apps | `GET /api/v1/apps` lists installed apps when `AppHost` is wired into the router. The family also covers local staged-bundle install, app lookup, start, stop, update, uninstall, token-free runtime status at `GET /api/v1/apps/{appId}/runtime`, and bounded token-redacted process logs at `GET /api/v1/apps/{appId}/logs`. | +| Apps | `GET /api/v1/apps` lists installed apps when `AppHost` is wired into the router. The family also covers local staged-bundle install, app lookup, start, stop, update, uninstall, declared permissions at `GET /api/v1/apps/{appId}/permissions`, recent app audit at `GET /api/v1/apps/{appId}/audit`, token-free runtime status at `GET /api/v1/apps/{appId}/runtime`, and bounded token-redacted process logs at `GET /api/v1/apps/{appId}/logs`. | | App catalogs | `GET /api/v1/app-catalogs` lists configured signed catalogs when catalog support is wired into the router. The family also covers source add/remove, refresh, catalog app listing/detail, and install/update from a verified catalog artifact. | ## Web Shell relationship diff --git a/platform-api/src/main/java/network/crypta/platform/api/AppAuditDecision.java b/platform-api/src/main/java/network/crypta/platform/api/AppAuditDecision.java new file mode 100644 index 0000000000..3b72666152 --- /dev/null +++ b/platform-api/src/main/java/network/crypta/platform/api/AppAuditDecision.java @@ -0,0 +1,44 @@ +package network.crypta.platform.api; + +/** + * High-level authorization outcome for one app-originated Platform API attempt. + * + *

The audit log stores this value after the bridge and router have decided whether a process + * that presented an app launch token may reach an endpoint handler. The enum is intentionally + * small: it captures the operator-relevant decision without storing request bodies, headers, raw + * tokens, or other transport details. Downstream JSON projections can therefore show the recent + * security posture of an app while keeping bearer credentials out of diagnostics and browser + * surfaces. + * + *

Most events are produced by the router after it maps a route to a capability. The + * authentication-failure value is reserved for callers that can detect a token-bearing request + * before a token-free app principal exists. + */ +public enum AppAuditDecision { + /** + * The app principal carried every capability required by the matched Platform API action. + * + *

An allowed event means the request passed the capability gate and was dispatched to the + * endpoint family. The final HTTP status may still be a validation or runtime error from the + * handler; the decision records authorization, not business-operation success. + */ + ALLOWED, + + /** + * The app principal authenticated successfully but lacked a required capability. + * + *

Denied events are recorded before handler dispatch. They are suitable for Web Shell + * summaries such as recent denied counts because they represent policy enforcement rather than + * malformed input inside an otherwise authorized endpoint. + */ + DENIED, + + /** + * Token authentication failed before the request could be represented as an app principal. + * + *

This decision is for transport layers that choose to audit failed bearer-token attempts. + * Events using it must still avoid recording the raw token and may omit the app id when no + * trusted identity was established. + */ + AUTH_FAILED +} diff --git a/platform-api/src/main/java/network/crypta/platform/api/AppAuditEvent.java b/platform-api/src/main/java/network/crypta/platform/api/AppAuditEvent.java new file mode 100644 index 0000000000..7950aaacd2 --- /dev/null +++ b/platform-api/src/main/java/network/crypta/platform/api/AppAuditEvent.java @@ -0,0 +1,61 @@ +package network.crypta.platform.api; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** + * Token-free audit record for one app-originated Platform API decision. + * + *

Events deliberately keep only route-family metadata and capability names. Query parameters, + * request bodies, launch tokens, local filesystem paths, and peer/request identifiers are excluded + * so the process-local audit trail is useful for operators without becoming a secondary secret + * store. + * + *

The record is a snapshot value. Its capability list is copied on construction, and callers + * receive the record through bounded log snapshots rather than a mutable live view. Keep the fields + * coarse when adding new audit uses: the event should explain which policy gate fired, not replay + * enough request detail to reconstruct sensitive operator or app data. + * + * @param timestamp UTC timestamp captured when the log accepted the event + * @param appId normalized app id when known, or {@code null} before identity exists + * @param method HTTP-style method name after bridge normalization, such as {@code GET} + * @param endpointFamily top-level Platform API endpoint family used for grouping + * @param action short deterministic route label selected by the capability matrix + * @param requiredCapabilities immutable capabilities required for the selected action + * @param decision authorization outcome recorded for operator-facing audit summaries + * @param statusCode HTTP-style status returned for the decision or completed request + * @param reasonCode stable machine-readable reason such as {@code missing_capability} + */ +public record AppAuditEvent( + Instant timestamp, + String appId, + String method, + String endpointFamily, + String action, + List requiredCapabilities, + AppAuditDecision decision, + int statusCode, + String reasonCode) { + /** + * Creates a validated token-free audit event. + * + *

The constructor accepts a {@code null} app id only for authentication-failure cases where no + * app identity has been established. All other fields are required because Web Shell and Apps API + * projections depend on them for stable display and filtering. The capability list is copied so a + * caller cannot mutate retained audit history after recording an event. + * + * @throws NullPointerException if timestamp, method, action metadata, decision, or reason is + * {@code null} + */ + public AppAuditEvent { + Objects.requireNonNull(timestamp, "timestamp"); + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(endpointFamily, "endpointFamily"); + Objects.requireNonNull(action, "action"); + requiredCapabilities = + List.copyOf(Objects.requireNonNull(requiredCapabilities, "requiredCapabilities")); + Objects.requireNonNull(decision, "decision"); + Objects.requireNonNull(reasonCode, "reasonCode"); + } +} diff --git a/platform-api/src/main/java/network/crypta/platform/api/AppAuditLog.java b/platform-api/src/main/java/network/crypta/platform/api/AppAuditLog.java new file mode 100644 index 0000000000..20e155caef --- /dev/null +++ b/platform-api/src/main/java/network/crypta/platform/api/AppAuditLog.java @@ -0,0 +1,183 @@ +package network.crypta.platform.api; + +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Objects; + +/** + * Bounded process-local audit log for app-originated Platform API decisions. + * + *

The log is intentionally in-memory only. It keeps the most recent {@link #DEFAULT_CAPACITY} + * events by default and drops the oldest entries when full. + * + *

This type is owned by a {@link PlatformApiRouter} instance and is not a durable compliance + * store. It exists to give the Web Shell and Apps API a recent, token-free view of capability + * enforcement for currently managed apps. All mutating and snapshot methods synchronize on the log + * instance, so callers can record and read from different request threads without seeing partial + * updates. The trade-off is simple bounded retention rather than persistence, indexing, or + * cross-process aggregation. + */ +public final class AppAuditLog { + /** + * Default maximum number of audit events retained by one router instance. + * + *

The value is intentionally small enough for cheap in-memory snapshots while still giving + * operators useful recent context. Older events are discarded silently when this bound is + * exceeded. + */ + public static final int DEFAULT_CAPACITY = 512; + + /** + * Default number of recent events returned for one app through the Apps API. + * + *

The Apps API uses this value for per-app detail views so the browser receives a concise + * recent-history slice instead of the full process-local ring buffer. + */ + public static final int DEFAULT_APP_EVENT_LIMIT = 25; + + private final int capacity; + private final Clock clock; + private final Deque events = new ArrayDeque<>(); + + /** + * Creates an audit log using the default capacity and system UTC clock. + * + *

This constructor is the normal production path. Tests that need deterministic timestamps can + * use the explicit constructor with a fixed {@link Clock}. + */ + public AppAuditLog() { + this(DEFAULT_CAPACITY, Clock.systemUTC()); + } + + /** + * Creates an audit log with an explicit capacity and clock. + * + *

The supplied capacity applies to the whole log, not to each app. When the log is full, + * recording one more event removes the oldest retained event regardless of app id. This keeps + * memory use fixed and predictable for long-running daemons. + * + * @param capacity maximum retained events across all app principals + * @param clock event timestamp source used when router decisions are recorded + * @throws IllegalArgumentException if {@code capacity} is zero or negative + */ + public AppAuditLog(int capacity, Clock clock) { + if (capacity <= 0) { + throw new IllegalArgumentException("capacity must be positive"); + } + this.capacity = capacity; + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** + * Appends one completed authorization decision. + * + *

The event is appended to the newest end of the log. If appending exceeds the configured + * capacity, the oldest retained events are dropped until the bound is restored. The method stores + * the already constructed event as a value object; callers should build events without raw + * tokens, request bodies, or local filesystem paths. + * + * @param event token-free audit event to append to the bounded log + */ + public synchronized void append(AppAuditEvent event) { + events.addLast(Objects.requireNonNull(event, "event")); + while (events.size() > capacity) { + events.removeFirst(); + } + } + + /** + * Appends an app-originated router decision. + * + *

Host/operator requests are ignored because this log is scoped to app-originated security + * decisions. For app principals, the method builds a token-free event from the request principal, + * selected action, HTTP-style status, and stable reason code. If no action is available, it falls + * back to an unmapped route label so denied default-deny cases are still visible. + * + * @param request authenticated request metadata already stripped of raw credentials + * @param authorization capability decision produced before endpoint dispatch + * @param decision audit decision to record for the app-originated request + * @param statusCode HTTP-style status code returned to the caller + * @param reasonCode stable machine-readable reason for the decision + */ + void appendDecision( + PlatformApiRequest request, + PlatformApiAuthorizationDecision authorization, + AppAuditDecision decision, + int statusCode, + String reasonCode) { + if (!request.principal().isApp()) { + return; + } + PlatformApiAction action = + authorization.optionalAction().orElseGet(() -> fallbackAction(request)); + append( + new AppAuditEvent( + Instant.now(clock), + request.principal().appId(), + request.method(), + action.endpointFamily(), + action.label(), + action.requiredCapabilities(), + decision, + statusCode, + reasonCode)); + } + + /** + * Returns recent events for one app in newest-first order. + * + *

The method scans the bounded log from newest to oldest and returns only events whose app id + * exactly matches the supplied id. A non-positive limit returns an empty list. The returned list + * is an immutable snapshot; subsequent log writes do not change it. + * + * @param appId normalized app id to match against retained events + * @param limit maximum number of matching events to return + * @return immutable newest-first event snapshot for the requested app + */ + public synchronized List recentForApp(String appId, int limit) { + Objects.requireNonNull(appId, "appId"); + if (limit <= 0) { + return List.of(); + } + ArrayList matches = new ArrayList<>(Math.min(limit, events.size())); + var descending = events.descendingIterator(); + while (descending.hasNext() && matches.size() < limit) { + AppAuditEvent event = descending.next(); + if (appId.equals(event.appId())) { + matches.add(event); + } + } + return List.copyOf(matches); + } + + /** + * Counts currently retained denied decisions for one app. + * + *

The count is derived only from events that still fit inside the bounded log. It is intended + * for recent operator context in app cards, not as an all-time denied-request counter. + * + * @param appId normalized app id to match against retained events + * @return denied event count within the current bounded in-memory log + */ + public synchronized long deniedCountForApp(String appId) { + Objects.requireNonNull(appId, "appId"); + return events.stream() + .filter(event -> appId.equals(event.appId())) + .filter(event -> event.decision() == AppAuditDecision.DENIED) + .count(); + } + + synchronized int size() { + return events.size(); + } + + private static PlatformApiAction fallbackAction(PlatformApiRequest request) { + List segments = request.pathSegments(); + String endpointFamily = segments.isEmpty() ? "unknown" : segments.getFirst(); + return PlatformApiAction.of(endpointFamily, endpointFamily + ".unmapped", List.of("unmapped")); + } +} diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAction.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAction.java new file mode 100644 index 0000000000..a4fe4588f7 --- /dev/null +++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAction.java @@ -0,0 +1,93 @@ +package network.crypta.platform.api; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.TreeSet; + +/** + * Capability-protected Platform API action selected from request method and route shape. + * + *

An action is the router-independent authorization description for one request shape. The + * capability matrix creates these values before endpoint dispatch so app principals can be checked + * consistently and audit events can use the same labels for allowed and denied decisions. The + * endpoint family is intentionally coarse, while the label is specific enough to distinguish + * mutations such as {@code queue.requests.remove} from read-only access. + * + *

Capabilities are normalized into sorted immutable order during construction. That keeps JSON + * output, audit entries, and tests deterministic even when a route requires more than one + * capability, such as a queue insert that needs both content-insert and queue-write authority. + * + * @param endpointFamily top-level endpoint family such as {@code queue} + * @param label short deterministic route/action label for audit output + * @param requiredCapabilities sorted capabilities required from an app principal + */ +public record PlatformApiAction( + String endpointFamily, String label, List requiredCapabilities) { + /** + * Creates an immutable route-action descriptor. + * + *

The constructor trims textual fields, rejects blank values, and copies the capability list + * into sorted immutable order. At least one capability is required because app principals are + * default-deny and every mapped action must name the manifest permission that grants access. + * + * @throws IllegalArgumentException if the endpoint, label, or capability set is blank or empty + * @throws NullPointerException if any required value or capability element is {@code null} + */ + public PlatformApiAction { + endpointFamily = requireText(endpointFamily, "endpointFamily"); + label = requireText(label, "label"); + requiredCapabilities = sortedCapabilities(requiredCapabilities); + if (requiredCapabilities.isEmpty()) { + throw new IllegalArgumentException("requiredCapabilities must not be empty"); + } + } + + /** + * Creates one action descriptor. + * + *

This factory is used by the capability matrix to keep route declarations compact. It does + * not bypass validation; the canonical record constructor still enforces non-blank labels and + * immutable sorted capabilities. + * + * @param endpointFamily top-level endpoint family used for grouping audit entries + * @param label deterministic audit label for the matched request shape + * @param requiredCapabilities capabilities required from app principals + * @return validated route-action descriptor for authorization and audit logging + */ + static PlatformApiAction of( + String endpointFamily, String label, Collection requiredCapabilities) { + return new PlatformApiAction(endpointFamily, label, List.copyOf(requiredCapabilities)); + } + + /** + * Returns the immutable sorted capability list required by this action. + * + *

A defensive copy is returned even though the record stores an immutable list. That keeps the + * accessor behavior explicit and prevents callers from depending on the concrete list instance + * retained by the record. + * + * @return immutable sorted capabilities required from an app principal + */ + @Override + public List requiredCapabilities() { + return List.copyOf(this.requiredCapabilities); + } + + private static List sortedCapabilities(Collection source) { + Objects.requireNonNull(source, "requiredCapabilities"); + TreeSet sorted = new TreeSet<>(); + for (String capability : source) { + sorted.add(requireText(capability, "requiredCapabilities value")); + } + return List.copyOf(sorted); + } + + private static String requireText(String value, String label) { + String text = Objects.requireNonNull(value, label).trim(); + if (text.isEmpty()) { + throw new IllegalArgumentException(label + " must not be blank"); + } + return text; + } +} diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAuthSource.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAuthSource.java new file mode 100644 index 0000000000..a6ca8f8aef --- /dev/null +++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAuthSource.java @@ -0,0 +1,42 @@ +package network.crypta.platform.api; + +/** + * Transport-side mechanism that established a Platform API principal. + * + *

The source is recorded separately from the principal type so tests and diagnostics can + * distinguish legacy local host access from launch-token based app access without carrying raw + * credentials through the transport-neutral API layer. + * + *

This enum describes how trust was established, not which authorization policy applies. The + * policy still follows the principal type: host/operator requests keep the existing local + * management behavior, while app principals are checked against manifest-declared capabilities. + * Keeping the source explicit helps bridges and tests verify that raw bearer tokens were consumed + * before a request reached the router. + */ +public enum PlatformApiAuthSource { + /** + * Legacy local host/operator access accepted by the HTTP bridge. + * + *

This value represents the current trusted local management model. It does not imply a named + * human user or role because host/operator RBAC is outside the app-permission enforcement phase. + */ + HOST_LOCAL, + + /** + * Opaque AppHost launch token verified by the transport bridge. + * + *

Requests with this source must carry only a token-free app principal after authentication. + * The raw token remains in the transport layer and must not appear in router state, audit events, + * JSON responses, or Web Shell bootstrap data. + */ + APP_TOKEN, + + /** + * No authenticated identity was established. + * + *

This value is available for tests and failure paths that need to model authentication before + * the bridge can construct a usable principal. Normal routed requests should use either + * host-local or app-token sources. + */ + UNAUTHENTICATED +} diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAuthorizationDecision.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAuthorizationDecision.java new file mode 100644 index 0000000000..2851ff34d3 --- /dev/null +++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiAuthorizationDecision.java @@ -0,0 +1,92 @@ +package network.crypta.platform.api; + +import java.util.Objects; +import java.util.Optional; + +/** + * Result of applying the app capability policy to a Platform API request. + * + *

The capability matrix produces this value before the router dispatches to an endpoint handler. + * For app principals, the decision carries the matched action so denied and allowed audit events + * can use the same endpoint family, label, and capability list. Host/operator requests are + * represented as allowed without an action because they bypass app capability checks under the + * current local-management model. + * + *

The reason code is intentionally stable and short. It is suitable for JSON error bodies, audit + * entries, and focused tests, but it is not a replacement for the operator-facing response message + * that explains the returned HTTP status. + * + * @param allowed whether the request may proceed to endpoint dispatch + * @param action route/action descriptor when the request matched a capability rule + * @param reasonCode stable audit/error reason code + */ +record PlatformApiAuthorizationDecision( + boolean allowed, PlatformApiAction action, String reasonCode) { + /** + * Creates a capability decision with a required reason code. + * + *

The action may be {@code null} only for host/operator allow decisions. Denied app decisions + * normally carry either a mapped route action or an unmapped fallback action so audit events can + * still identify the endpoint family that was blocked. + * + * @throws NullPointerException if {@code reasonCode} is {@code null} + */ + PlatformApiAuthorizationDecision { + Objects.requireNonNull(reasonCode, "reasonCode"); + } + + /** + * Creates an allowed app-principal decision for a matched action. + * + *

The caller has already confirmed that the app principal carries every capability listed by + * the action. The router will dispatch the request and record the eventual handler status as an + * allowed authorization decision. + * + * @param action matched action whose required capabilities were present + * @return allowed capability decision for app-originated routing + */ + static PlatformApiAuthorizationDecision allowed(PlatformApiAction action) { + return new PlatformApiAuthorizationDecision(true, action, "capability_present"); + } + + /** + * Creates the compatibility allow decision for host/operator requests. + * + *

Host/operator authorization is enforced by the transport bridge before routing. The + * capability matrix therefore returns an allowed decision with no app action and a reason code + * that makes tests and diagnostics explicit about the bypass. + * + * @return allowed decision for trusted local host/operator traffic + */ + static PlatformApiAuthorizationDecision hostAllowed() { + return new PlatformApiAuthorizationDecision(true, null, "host_operator"); + } + + /** + * Creates a denied app-principal decision. + * + *

The action describes either the mapped route whose capabilities were missing or the + * synthetic unmapped action used by default-deny. The reason code should stay stable because it + * is written to audit entries and may be used by Web Shell summaries. + * + * @param action action that explains which route or fallback policy denied the request + * @param reasonCode stable machine-readable reason for the denial + * @return denied capability decision that must not be dispatched to handlers + */ + static PlatformApiAuthorizationDecision denied(PlatformApiAction action, String reasonCode) { + return new PlatformApiAuthorizationDecision(false, action, reasonCode); + } + + /** + * Returns the matched action when one was part of the decision. + * + *

Host/operator allow decisions intentionally have no action because they are outside the app + * capability matrix. Audit code can use this optional form to choose between a mapped action and + * a fallback label without inspecting the nullable record component directly. + * + * @return optional action associated with app-principal decisions + */ + Optional optionalAction() { + return Optional.ofNullable(action); + } +} diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiCapabilities.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiCapabilities.java new file mode 100644 index 0000000000..2a2b5090a7 --- /dev/null +++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiCapabilities.java @@ -0,0 +1,575 @@ +package network.crypta.platform.api; + +import java.util.List; +import java.util.Set; + +/** + * Central app-principal capability matrix for Platform API v1. + * + *

Host/operator requests bypass this matrix to preserve the existing local-management model. App + * principals are default-deny: a request must match one of the route rules below and the app + * principal must carry every required manifest permission. + * + *

The matrix is intentionally explicit instead of reflection- or annotation-driven. Each helper + * mirrors one top-level endpoint family so reviewers can see which routes grant read access and + * which routes require write or manage authority. Unrecognized methods, unsupported sub-routes, and + * malformed route shapes return no action and are denied for app principals before endpoint + * dispatch. + */ +final class PlatformApiCapabilities { + /** Manifest permission that allows app principals to read node identity and status endpoints. */ + static final String NODE_READ = "node.read"; + + /** Manifest permission that allows app principals to read connectivity diagnostics. */ + static final String CONNECTIVITY_READ = "connectivity.read"; + + /** Manifest permission that allows app principals to read queue pages and completion state. */ + static final String QUEUE_READ = "queue.read"; + + /** Manifest permission that allows app principals to mutate existing queue entries. */ + static final String QUEUE_WRITE = "queue.write"; + + /** Manifest permission that allows app principals to create local file or directory inserts. */ + static final String CONTENT_INSERT = "content.insert"; + + /** Manifest permission that allows app principals to read peer summaries and peer details. */ + static final String PEERS_READ = "peers.read"; + + /** Manifest permission that allows app principals to add, update, annotate, or remove peers. */ + static final String PEERS_WRITE = "peers.write"; + + /** Manifest permission that allows app principals to read configuration projections. */ + static final String CONFIG_READ = "config.read"; + + /** Manifest permission that allows app principals to change or persist configuration values. */ + static final String CONFIG_WRITE = "config.write"; + + /** Manifest permission that allows app principals to read security-level state. */ + static final String SECURITY_READ = "security.read"; + + /** Manifest permission that allows app principals to change security-level settings. */ + static final String SECURITY_WRITE = "security.write"; + + /** Manifest permission that allows app principals to read core update state. */ + static final String UPDATES_READ = "updates.read"; + + /** Manifest permission that allows app principals to trigger core update actions. */ + static final String UPDATES_WRITE = "updates.write"; + + /** Manifest permission that allows app principals to read first-time wizard state. */ + static final String WIZARD_READ = "wizard.read"; + + /** Manifest permission that allows app principals to submit first-time wizard choices. */ + static final String WIZARD_WRITE = "wizard.write"; + + /** Manifest permission that allows app principals to read current runtime alerts. */ + static final String ALERTS_READ = "alerts.read"; + + /** Manifest permission that allows app principals to dismiss operator-visible alerts. */ + static final String ALERTS_WRITE = "alerts.write"; + + /** Manifest permission that allows app principals to read runtime diagnostic summaries. */ + static final String DIAGNOSTICS_READ = "diagnostics.read"; + + /** Manifest permission that allows app principals to read installed app summaries. */ + static final String APPS_READ = "apps.read"; + + /** + * Manifest permission that allows app principals to install, update, start, stop, or remove apps. + */ + static final String APPS_MANAGE = "apps.manage"; + + /** + * Manifest permission that allows app principals to read signed app-catalog sources and entries. + */ + static final String CATALOGS_READ = "catalogs.read"; + + /** Manifest permission that allows app principals to add, refresh, or install from catalogs. */ + static final String CATALOGS_MANAGE = "catalogs.manage"; + + private static final String FAMILY_ALERTS = "alerts"; + private static final String FAMILY_APP_CATALOGS = "app-catalogs"; + private static final String FAMILY_APPS = "apps"; + private static final String FAMILY_CONFIG = "config"; + private static final String FAMILY_PEERS = "peers"; + private static final String FAMILY_QUEUE = "queue"; + private static final String FAMILY_SECURITY_LEVELS = "security-levels"; + private static final String FAMILY_UPDATES = "updates"; + private static final String FAMILY_WIZARD = "wizard"; + + /** Prevents construction of this stateless capability-mapping utility. */ + private PlatformApiCapabilities() {} + + /** + * Authorizes one transport-neutral Platform API request. + * + *

Host/operator principals are allowed immediately because the HTTP bridge has already + * enforced the legacy local management checks. App principals must match a route rule and must + * contain every capability required by that action. Unsupported app routes are denied with an + * unmapped action so the audit log can still record which endpoint family was attempted. + * + * @param request request metadata with an already established principal + * @return capability decision used by the router before endpoint dispatch + */ + static PlatformApiAuthorizationDecision authorize(PlatformApiRequest request) { + if (!request.principal().isApp()) { + return PlatformApiAuthorizationDecision.hostAllowed(); + } + + PlatformApiAction action = actionFor(request); + if (action == null) { + return PlatformApiAuthorizationDecision.denied(unknownAction(request), "unmapped_route"); + } + Set permissions = Set.copyOf(request.principal().permissions()); + if (permissions.containsAll(action.requiredCapabilities())) { + return PlatformApiAuthorizationDecision.allowed(action); + } + return PlatformApiAuthorizationDecision.denied(action, "missing_capability"); + } + + /** + * Resolves the route/action descriptor for one request. + * + *

The first path segment selects the endpoint family. Each family helper is responsible for + * checking method and route shape. Returning {@code null} is deliberate: app principals treat + * every unmatched route as default-deny rather than falling through to handler-level 404 or 405 + * responses. + * + * @param request request metadata whose path is relative to the Platform API v1 prefix + * @return matched action descriptor, or {@code null} when no app capability rule applies + */ + private static PlatformApiAction actionFor(PlatformApiRequest request) { + List segments = request.pathSegments(); + if (segments.isEmpty()) { + return null; + } + String family = segments.getFirst(); + String method = request.method(); + return switch (family) { + case "node" -> nodeAction(method, segments); + case "connectivity" -> + exactGet(method, family, CONNECTIVITY_READ, CONNECTIVITY_READ, segments.size() == 1); + case "diagnostics" -> + exactGet(method, family, DIAGNOSTICS_READ, DIAGNOSTICS_READ, segments.size() == 1); + case FAMILY_ALERTS -> alertsAction(method, segments); + case FAMILY_CONFIG -> configAction(method, segments); + case FAMILY_SECURITY_LEVELS -> securityAction(method, segments); + case FAMILY_UPDATES -> updatesAction(method, segments); + case FAMILY_WIZARD -> wizardAction(method, segments); + case FAMILY_QUEUE -> queueAction(method, segments); + case FAMILY_PEERS -> peersAction(method, segments); + case FAMILY_APPS -> appsAction(method, segments); + case FAMILY_APP_CATALOGS -> catalogsAction(method, segments); + default -> null; + }; + } + + /** + * Maps the node identity and reference reads. + * + *

The router exposes only named node read resources, not a generic node subtree. Unsupported + * child paths therefore stay unmapped for app principals. + * + * @param method normalized HTTP-style method name + * @param segments decoded route segments beneath the API v1 prefix + * @return matched node read action, or {@code null} for unsupported node routes + */ + private static PlatformApiAction nodeAction(String method, List segments) { + return exactGet( + method, + "node", + NODE_READ, + NODE_READ, + segments.size() == 2 + && ("greeting".equals(segments.get(1)) || "reference".equals(segments.get(1)))); + } + + /** + * Maps alert feed and dismissal routes. + * + *

All alert reads require {@link #ALERTS_READ}. The only current alert mutation exposed + * through Platform API v1 is dismissal, which requires {@link #ALERTS_WRITE}. + * + * @param method normalized HTTP-style method name + * @param segments decoded route segments beneath the API v1 prefix + * @return matched alerts action, or {@code null} for unsupported alert routes + */ + private static PlatformApiAction alertsAction(String method, List segments) { + if ("GET".equals(method) && segments.size() == 1) { + return action(FAMILY_ALERTS, ALERTS_READ, ALERTS_READ); + } + if ("POST".equals(method) && segments.size() == 3 && "dismiss".equals(segments.get(2))) { + return action(FAMILY_ALERTS, "alerts.dismiss", ALERTS_WRITE); + } + return null; + } + + /** + * Maps configuration read and write routes. + * + *

The root configuration export requires {@link #CONFIG_READ}. Writes are limited to the + * explicit override and persist endpoints and require {@link #CONFIG_WRITE}; unsupported shapes + * remain unmapped for app principals. + * + * @param method normalized HTTP-style method name + * @param segments decoded route segments beneath the API v1 prefix + * @return matched configuration action, or {@code null} for unsupported config routes + */ + private static PlatformApiAction configAction(String method, List segments) { + if ("GET".equals(method) && segments.size() == 1) { + return action(FAMILY_CONFIG, CONFIG_READ, CONFIG_READ); + } + if ("POST".equals(method) + && segments.size() == 2 + && ("overrides".equals(segments.get(1)) || "persist".equals(segments.get(1)))) { + return action(FAMILY_CONFIG, "config." + segments.get(1), CONFIG_WRITE); + } + return null; + } + + /** + * Maps security-level read and mutation routes. + * + *

Security-level reads require {@link #SECURITY_READ}. Mutations are restricted to the network + * and physical threat-level endpoints and require {@link #SECURITY_WRITE}. Keeping every allowed + * route name explicit avoids granting future security routes by accident. + * + * @param method normalized HTTP-style method name + * @param segments decoded route segments beneath the API v1 prefix + * @return matched security action, or {@code null} for unsupported security routes + */ + private static PlatformApiAction securityAction(String method, List segments) { + if ("GET".equals(method)) { + return securityReadAction(segments); + } + if ("POST".equals(method) + && segments.size() == 2 + && ("network".equals(segments.get(1)) || "physical".equals(segments.get(1)))) { + return action(FAMILY_SECURITY_LEVELS, "security." + segments.get(1), SECURITY_WRITE); + } + return null; + } + + private static PlatformApiAction securityReadAction(List segments) { + if (segments.size() == 1) { + return action(FAMILY_SECURITY_LEVELS, SECURITY_READ, SECURITY_READ); + } + if (segments.size() == 2 && "network-warning".equals(segments.get(1))) { + return action(FAMILY_SECURITY_LEVELS, "security.network-warning", SECURITY_READ); + } + return null; + } + + /** + * Maps core update read and trigger routes. + * + *

Update status reads require {@link #UPDATES_READ}. The current write surface is the core + * package download trigger, which requires {@link #UPDATES_WRITE}. Other updater operations stay + * host/operator-only until they are deliberately added to this matrix. + * + * @param method normalized HTTP-style method name + * @param segments decoded route segments beneath the API v1 prefix + * @return matched updater action, or {@code null} for unsupported updater routes + */ + private static PlatformApiAction updatesAction(String method, List segments) { + if ("GET".equals(method) && segments.size() == 2 && "core".equals(segments.get(1))) { + return action(FAMILY_UPDATES, UPDATES_READ, UPDATES_READ); + } + if ("POST".equals(method) + && segments.size() == 3 + && "core".equals(segments.get(1)) + && "download".equals(segments.get(2))) { + return action(FAMILY_UPDATES, "updates.core.download", UPDATES_WRITE); + } + return null; + } + + /** + * Maps first-time wizard read and apply routes. + * + *

Wizard reads require {@link #WIZARD_READ}. Applying submitted wizard choices is the only + * mapped mutation and requires {@link #WIZARD_WRITE}. The path check includes the {@code + * first-time} segment so future wizard families do not inherit this permission accidentally. + * + * @param method normalized HTTP-style method name + * @param segments decoded route segments beneath the API v1 prefix + * @return matched wizard action, or {@code null} for unsupported wizard routes + */ + private static PlatformApiAction wizardAction(String method, List segments) { + if ("GET".equals(method) && segments.size() == 2 && "first-time".equals(segments.get(1))) { + return action(FAMILY_WIZARD, WIZARD_READ, WIZARD_READ); + } + if ("POST".equals(method) + && segments.size() == 3 + && "first-time".equals(segments.get(1)) + && "apply".equals(segments.get(2))) { + return action(FAMILY_WIZARD, "wizard.first-time.apply", WIZARD_WRITE); + } + return null; + } + + /** + * Maps queue read, download, insert, cleanup, and request-control routes. + * + *

Queue reads require {@link #QUEUE_READ}. Mutations that operate on existing requests require + * {@link #QUEUE_WRITE}. Insert creation is stricter because it can publish local content, so file + * and directory insert routes require both {@link #CONTENT_INSERT} and {@link #QUEUE_WRITE}. + * + * @param method normalized HTTP-style method name + * @param segments decoded route segments beneath the API v1 prefix + * @return matched queue action, or {@code null} for unsupported queue routes + */ + private static PlatformApiAction queueAction(String method, List segments) { + if ("GET".equals(method)) { + return queueReadAction(segments); + } + if (!"POST".equals(method)) { + return null; + } + if (segments.size() == 2 && "downloads".equals(segments.get(1))) { + return action(FAMILY_QUEUE, "queue.downloads.create", QUEUE_WRITE); + } + if (segments.size() != 3) { + return null; + } + String resource = segments.get(1); + String action = segments.get(2); + if ("inserts".equals(resource) && ("file".equals(action) || "directory".equals(action))) { + return queueInsertAction("queue.inserts." + action); + } + if ("requests".equals(resource) + && ("remove".equals(action) || "restart".equals(action) || "priority".equals(action))) { + return action(FAMILY_QUEUE, "queue.requests." + action, QUEUE_WRITE); + } + if ("cleanup".equals(resource) && ("uploads".equals(action) || "downloads".equals(action))) { + return action(FAMILY_QUEUE, "queue.cleanup." + action, QUEUE_WRITE); + } + return null; + } + + /** + * Maps explicit read-only queue routes. + * + *

The queue family also contains mutation-shaped resources such as {@code requests/remove} and + * creation resources such as {@code downloads}. Those routes are not valid GET reads, so app + * principals must not receive a blanket {@code queue.read} grant for every queue path. + * + * @param segments decoded route segments beneath the API v1 prefix + * @return queue read action for supported read shapes, or {@code null} for unsupported routes + */ + private static PlatformApiAction queueReadAction(List segments) { + if (segments.size() == 1 + || (segments.size() == 2 + && ("count".equals(segments.get(1)) || "keys".equals(segments.get(1))))) { + return action(FAMILY_QUEUE, QUEUE_READ, QUEUE_READ); + } + return null; + } + + /** + * Maps peer read and peer-management routes. + * + *

Peer reads require {@link #PEERS_READ}. Adding peers and changing an existing peer's + * settings, note, or removal state require {@link #PEERS_WRITE}. The helper intentionally checks + * exact terminal route names because peer identifiers occupy the middle segment. + * + * @param method normalized HTTP-style method name + * @param segments decoded route segments beneath the API v1 prefix + * @return matched peer action, or {@code null} for unsupported peer routes + */ + private static PlatformApiAction peersAction(String method, List segments) { + if ("GET".equals(method) && (segments.size() == 1 || segments.size() == 2)) { + return action(FAMILY_PEERS, PEERS_READ, PEERS_READ); + } + if ("POST".equals(method) && segments.size() == 2 && "add".equals(segments.get(1))) { + return action(FAMILY_PEERS, "peers.add", PEERS_WRITE); + } + if ("POST".equals(method) + && segments.size() == 3 + && ("settings".equals(segments.get(2)) + || "note".equals(segments.get(2)) + || "remove".equals(segments.get(2)))) { + return action(FAMILY_PEERS, "peers." + segments.get(2), PEERS_WRITE); + } + return null; + } + + /** + * Maps installed-app inventory and lifecycle routes. + * + *

Reading app summaries, permissions, audit entries, logs, and runtime state all fall under + * {@link #APPS_READ}. Lifecycle and installation actions require {@link #APPS_MANAGE}. This route + * family is powerful because it can affect other apps, so new mutations should be added here only + * when they are intended for app principals. + * + * @param method normalized HTTP-style method name + * @param segments decoded route segments beneath the API v1 prefix + * @return matched app-management action, or {@code null} for unsupported app routes + */ + private static PlatformApiAction appsAction(String method, List segments) { + if ("GET".equals(method)) { + return appsReadAction(segments); + } + if ("DELETE".equals(method) && segments.size() == 2) { + return action(FAMILY_APPS, "apps.uninstall", APPS_MANAGE); + } + if (!"POST".equals(method)) { + return null; + } + if (segments.size() == 2 && "install".equals(segments.get(1))) { + return action(FAMILY_APPS, "apps.install", APPS_MANAGE); + } + if (segments.size() == 3 + && ("start".equals(segments.get(2)) + || "stop".equals(segments.get(2)) + || "update".equals(segments.get(2)))) { + return action(FAMILY_APPS, "apps." + segments.get(2), APPS_MANAGE); + } + return null; + } + + /** + * Maps explicit read-only app routes. + * + *

GET is deliberately not treated as a blanket grant for {@code /apps/**}. Lifecycle action + * names such as {@code start} and unsupported child resources must stay unmapped for app + * principals so the router records a default-deny authorization decision instead of allowing the + * request to reach handler-level 404 or 405 handling. + * + * @param segments decoded route segments beneath the API v1 prefix + * @return app read action for supported read shapes, or {@code null} for unsupported routes + */ + private static PlatformApiAction appsReadAction(List segments) { + if (segments.size() == 1 || segments.size() == 2) { + return action(FAMILY_APPS, APPS_READ, APPS_READ); + } + if (segments.size() == 3 + && ("runtime".equals(segments.get(2)) + || "logs".equals(segments.get(2)) + || "permissions".equals(segments.get(2)) + || "audit".equals(segments.get(2)))) { + return action(FAMILY_APPS, APPS_READ, APPS_READ); + } + return null; + } + + /** + * Maps signed app-catalog read and management routes. + * + *

Catalog source and entry reads require {@link #CATALOGS_READ}. Adding, refreshing, removing, + * installing from, or updating from a catalog requires {@link #CATALOGS_MANAGE}. The installation + * and update route shape includes both catalog id and app id, so this helper checks the fixed + * structural segments and ignores identifier values. + * + * @param method normalized HTTP-style method name + * @param segments decoded route segments beneath the API v1 prefix + * @return matched catalog action, or {@code null} for unsupported catalog routes + */ + private static PlatformApiAction catalogsAction(String method, List segments) { + if ("GET".equals(method)) { + return catalogsReadAction(segments); + } + if ("DELETE".equals(method) && segments.size() == 2) { + return action(FAMILY_APP_CATALOGS, "catalogs.remove", CATALOGS_MANAGE); + } + if (!"POST".equals(method)) { + return null; + } + if (segments.size() == 2 && "add".equals(segments.get(1))) { + return action(FAMILY_APP_CATALOGS, "catalogs.add", CATALOGS_MANAGE); + } + if (segments.size() == 3 && "refresh".equals(segments.get(2))) { + return action(FAMILY_APP_CATALOGS, "catalogs.refresh", CATALOGS_MANAGE); + } + if (segments.size() == 5 + && "apps".equals(segments.get(2)) + && ("install".equals(segments.get(4)) || "update".equals(segments.get(4)))) { + return action(FAMILY_APP_CATALOGS, "catalogs.apps." + segments.get(4), CATALOGS_MANAGE); + } + return null; + } + + /** + * Maps explicit read-only app-catalog routes. + * + *

The catalog family has management resources such as {@code /app-catalogs/{catalogId}} and + * action resources such as {@code refresh}. Those are not valid GET reads in the router, so app + * principals must not receive a blanket {@code catalogs.read} grant for every catalog path. + * + * @param segments decoded route segments beneath the API v1 prefix + * @return catalog read action for supported read shapes, or {@code null} for unsupported routes + */ + private static PlatformApiAction catalogsReadAction(List segments) { + if (segments.size() == 1 || isCatalogAppRead(segments)) { + return action(FAMILY_APP_CATALOGS, CATALOGS_READ, CATALOGS_READ); + } + return null; + } + + private static boolean isCatalogAppRead(List segments) { + return (segments.size() == 3 || segments.size() == 4) && "apps".equals(segments.get(2)); + } + + /** + * Builds an action only when the request method is {@code GET} and the route shape is supported. + * + *

Read-only endpoint families use this helper after checking the exact route shape. + * Unsupported methods or shapes return {@code null} so app principals are denied before dispatch + * rather than reaching a handler-level method or not-found check. + * + * @param method normalized HTTP-style method name + * @param endpointFamily top-level endpoint family used in audit output + * @param label deterministic action label used in audit output + * @param capability single required manifest capability + * @param routeMatches whether the request path is one supported read route + * @return read action for supported {@code GET} routes, or {@code null} otherwise + */ + private static PlatformApiAction exactGet( + String method, String endpointFamily, String label, String capability, boolean routeMatches) { + return "GET".equals(method) && routeMatches ? action(endpointFamily, label, capability) : null; + } + + /** + * Builds an action requiring one capability. + * + * @param endpointFamily top-level endpoint family used in audit output + * @param label deterministic action label used in audit output + * @param capability required manifest capability for the action + * @return action descriptor with one required capability + */ + private static PlatformApiAction action(String endpointFamily, String label, String capability) { + return PlatformApiAction.of(endpointFamily, label, List.of(capability)); + } + + /** + * Builds the action for queue insert routes. + * + *

Queue inserts are intentionally stricter than ordinary queue mutations: a manifest must + * grant both permission to insert content and permission to mutate the local queue. Keeping the + * fixed family and capability pair here makes that policy visible at the route helper boundary + * while the action record still sorts the capabilities for deterministic audit output. + * + * @param label deterministic queue insert action label used in audit output + * @return action descriptor requiring both content insertion and queue mutation capabilities + */ + private static PlatformApiAction queueInsertAction(String label) { + return PlatformApiAction.of(FAMILY_QUEUE, label, List.of(CONTENT_INSERT, QUEUE_WRITE)); + } + + /** + * Builds the synthetic action used for default-deny app routes. + * + *

The returned action is not grantable by a normal manifest permission. Its purpose is to + * provide audit entries with an endpoint family and an {@code unmapped} capability label when an + * app principal attempts a route that the matrix does not intentionally expose. + * + * @param request request whose first path segment identifies the attempted endpoint family + * @return synthetic unmapped action for denied app-principal audit events + */ + private static PlatformApiAction unknownAction(PlatformApiRequest request) { + List segments = request.pathSegments(); + String endpointFamily = segments.isEmpty() ? "unknown" : segments.getFirst(); + String label = segments.isEmpty() ? "unmapped" : endpointFamily + ".unmapped"; + return PlatformApiAction.of(endpointFamily, label, List.of("unmapped")); + } +} diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipal.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipal.java new file mode 100644 index 0000000000..8c51a8ce69 --- /dev/null +++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipal.java @@ -0,0 +1,141 @@ +package network.crypta.platform.api; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.TreeSet; + +/** + * Token-free identity attached to one Platform API request. + * + *

The principal intentionally stores only stable identity metadata and manifest-declared + * permissions. Raw launch tokens, headers, and transport details remain outside this model so they + * cannot appear in router JSON, audit entries, snapshots, or diagnostic strings by accident. + * + *

There are two valid shapes. A host/operator principal has no app id and no app permissions; it + * represents the existing trusted local management path. An app principal has a normalized app id, + * an {@link PlatformApiAuthSource#APP_TOKEN} source, and the manifest permissions from the + * currently running app snapshot. The router uses that immutable permission list for default-deny + * capability checks. + * + * @param type principal category used by the router's authorization path + * @param authSource transport-side authentication source that established the identity + * @param appId normalized app id for app principals, or {@code null} for host/operator principals + * @param permissions immutable sorted app permissions for app principals + */ +public record PlatformApiPrincipal( + PlatformApiPrincipalType type, + PlatformApiAuthSource authSource, + String appId, + List permissions) { + /** + * Creates a validated token-free principal. + * + *

The constructor enforces the two supported principal shapes and normalizes app permissions + * into sorted immutable order. It trims app ids and permission strings, rejects blank app ids for + * app principals, and rejects any host/operator principal that accidentally carries app identity + * or capabilities. + * + * @throws IllegalArgumentException if the identity fields do not match the principal type + * @throws NullPointerException if {@code type}, {@code authSource}, or {@code permissions} is + * {@code null}, or if a permission element is {@code null} + */ + public PlatformApiPrincipal { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(authSource, "authSource"); + permissions = sortedPermissions(permissions); + if (type == PlatformApiPrincipalType.APP) { + if (appId == null || appId.isBlank()) { + throw new IllegalArgumentException("app principal requires an app id"); + } + appId = appId.trim(); + if (authSource != PlatformApiAuthSource.APP_TOKEN) { + throw new IllegalArgumentException("app principal requires APP_TOKEN auth source"); + } + } else { + if (appId != null) { + throw new IllegalArgumentException("host principal must not carry an app id"); + } + if (!permissions.isEmpty()) { + throw new IllegalArgumentException("host principal must not carry app permissions"); + } + if (authSource != PlatformApiAuthSource.HOST_LOCAL) { + throw new IllegalArgumentException("host principal requires HOST_LOCAL auth source"); + } + } + } + + /** + * Returns the default host/operator principal used by existing tests and call sites. + * + *

This factory preserves compatibility for transport bridges and tests that predate app + * principals. Requests built with this principal continue through the legacy host/operator path + * and do not require manifest capability strings. + * + * @return trusted local host/operator principal with no app id or permissions + */ + public static PlatformApiPrincipal hostOperator() { + return new PlatformApiPrincipal( + PlatformApiPrincipalType.HOST_OPERATOR, PlatformApiAuthSource.HOST_LOCAL, null, List.of()); + } + + /** + * Builds an app principal from an AppHost-verified launch token identity. + * + *

The caller must authenticate the launch token before invoking this factory. The raw token is + * deliberately absent from the resulting value; only the app id and manifest permission strings + * needed for authorization cross the transport-neutral boundary. + * + * @param appId normalized app id associated with the verified live app process + * @param permissions manifest-declared permissions for the currently running app + * @return token-free app principal ready for Platform API capability checks + */ + public static PlatformApiPrincipal appToken(String appId, Collection permissions) { + return new PlatformApiPrincipal( + PlatformApiPrincipalType.APP, + PlatformApiAuthSource.APP_TOKEN, + appId, + List.copyOf(permissions)); + } + + /** + * Returns whether this request was authenticated as an app process. + * + *

This convenience method is used by the router and audit log to separate app-originated + * decisions from host/operator traffic. It does not re-check the authentication source; the + * constructor already enforces that app principals use the app-token source. + * + * @return {@code true} for app principals and {@code false} for host/operator principals + */ + public boolean isApp() { + return type == PlatformApiPrincipalType.APP; + } + + /** + * Returns immutable sorted manifest permissions carried by this principal. + * + *

Host/operator principals always return an empty list. App principals return the permissions + * captured after launch-token authentication. A defensive copy is returned so callers cannot + * mutate the record's retained authorization view. + * + * @return immutable sorted permission strings for app principals, or an empty list for host + * principals + */ + @Override + public List permissions() { + return List.copyOf(this.permissions); + } + + private static List sortedPermissions(Collection source) { + Objects.requireNonNull(source, "permissions"); + TreeSet sorted = new TreeSet<>(); + for (String permission : source) { + String normalized = Objects.requireNonNull(permission, "permissions value").trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("permissions must not contain blank values"); + } + sorted.add(normalized); + } + return List.copyOf(sorted); + } +} diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipalType.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipalType.java new file mode 100644 index 0000000000..d0ddf8b6f4 --- /dev/null +++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipalType.java @@ -0,0 +1,32 @@ +package network.crypta.platform.api; + +/** + * Identity category used for Platform API authorization decisions. + * + *

The current Platform API distinguishes trusted local host/operator requests from + * app-originated process requests. Host/operator authorization remains the legacy local-management + * model, while app principals are capability checked against manifest-declared permissions. + * + *

The type is intentionally independent of the transport source. A bridge can record how a + * principal was established with {@link PlatformApiAuthSource}, while the router uses this enum to + * choose the authorization path. That separation keeps the model ready for future transport sources + * without changing the current app-principal default-deny rule. + */ +public enum PlatformApiPrincipalType { + /** + * Trusted local operator request accepted by the host-facing transport bridge. + * + *

This category keeps existing local Web Shell and management flows working. It does not carry + * app permissions and is not audited as app-originated traffic by the app audit log. + */ + HOST_OPERATOR, + + /** + * Request authenticated as a running app process. + * + *

App principals are authorized against the immutable manifest permissions attached to the + * principal. They are denied by default when a route is not mapped or the required capability is + * absent. + */ + APP +} diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiRequest.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiRequest.java index a15bc4551e..76ac119df8 100644 --- a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiRequest.java +++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiRequest.java @@ -11,16 +11,37 @@ * *

The record intentionally captures only the small amount of information that the current API * needs: the HTTP-style method, the relative path split into path segments beneath {@link - * PlatformApiPaths#API_V1_PREFIX}, and the decoded query parameters with all supplied values. - * Bridges remain responsible for any transport-specific parsing, authentication, or header handling - * before constructing this request. + * PlatformApiPaths#API_V1_PREFIX}, the decoded query parameters with all supplied values, and the + * token-free principal established by the transport bridge. Bridges remain responsible for any + * transport-specific parsing, authentication, credential handling, or header parsing before + * constructing this request. * * @param method request method name such as {@code GET} * @param pathSegments relative path segments beneath the API v1 mount point * @param queryParameters decoded query parameters in encounter order + * @param principal token-free request principal */ public record PlatformApiRequest( - String method, List pathSegments, Map> queryParameters) { + String method, + List pathSegments, + Map> queryParameters, + PlatformApiPrincipal principal) { + /** + * Creates a host/operator request descriptor. + * + *

This overload preserves existing tests and call sites that predate app-principal + * authorization. Requests built this way keep the legacy local host/operator authorization + * behavior. + * + * @param method request method name such as {@code GET} + * @param pathSegments relative path segments beneath the API v1 mount point + * @param queryParameters decoded query parameters in encounter order + */ + public PlatformApiRequest( + String method, List pathSegments, Map> queryParameters) { + this(method, pathSegments, queryParameters, PlatformApiPrincipal.hostOperator()); + } + /** * Creates an immutable platform API request descriptor. * @@ -39,6 +60,7 @@ public record PlatformApiRequest( .map(segment -> Objects.requireNonNull(segment, "pathSegments value")) .toList()); queryParameters = immutableQueryParameters(queryParameters); + Objects.requireNonNull(principal, "principal"); } /** diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiResponse.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiResponse.java index a2d51a4077..47a1086a20 100644 --- a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiResponse.java +++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiResponse.java @@ -104,6 +104,7 @@ static String reasonPhrase(int statusCode) { case 201 -> "Created"; case 200 -> "OK"; case 400 -> "Bad Request"; + case 401 -> "Unauthorized"; case 403 -> "Forbidden"; case 404 -> "Not Found"; case 405 -> "Method Not Allowed"; diff --git a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiRouter.java b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiRouter.java index 8a11dece42..cc98bd2b22 100644 --- a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiRouter.java +++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiRouter.java @@ -77,6 +77,9 @@ public final class PlatformApiRouter { /** Handler for the {@code /apps/...} endpoint family, when AppHost support is available. */ private final AppsApiHandler appsApiHandler; + /** Bounded process-local audit log for app-originated authorization decisions. */ + private final AppAuditLog appAuditLog; + /** * Handler for the {@code /app-catalogs/...} endpoint family, when catalog support is available. */ @@ -132,7 +135,17 @@ public PlatformApiRouter( AppHost appHost, AppCatalogManager appCatalogManager, LegacyAdminUsagePort legacyAdminUsage) { + this(runtimePorts, appHost, appCatalogManager, legacyAdminUsage, new AppAuditLog()); + } + + PlatformApiRouter( + RuntimePorts runtimePorts, + AppHost appHost, + AppCatalogManager appCatalogManager, + LegacyAdminUsagePort legacyAdminUsage, + AppAuditLog appAuditLog) { Objects.requireNonNull(runtimePorts, "runtimePorts"); + this.appAuditLog = Objects.requireNonNull(appAuditLog, "appAuditLog"); nodeApiHandler = new NodeApiHandler(runtimePorts.nodeInfo()); peersApiHandler = new PeersApiHandler(runtimePorts.peer(), runtimePorts.darknetConnections()); configApiHandler = new ConfigApiHandler(runtimePorts.config()); @@ -152,7 +165,7 @@ public PlatformApiRouter( runtimePorts.queueCompletion()); alertsApiHandler = new AlertsApiHandler(runtimePorts.alertFeed(), runtimePorts.alertMutation()); diagnosticsApiHandler = new DiagnosticsApiHandler(runtimePorts.diagnostic(), legacyAdminUsage); - appsApiHandler = appHost == null ? null : new AppsApiHandler(appHost); + appsApiHandler = appHost == null ? null : new AppsApiHandler(appHost, this.appAuditLog); appCatalogsApiHandler = appHost == null || appCatalogManager == null ? null @@ -170,13 +183,52 @@ public PlatformApiRouter( * @return JSON response for the routed endpoint */ public PlatformApiResponse route(PlatformApiRequest request) { + PlatformApiRequest checkedRequest = Objects.requireNonNull(request, "request"); + PlatformApiAuthorizationDecision authorization = + PlatformApiCapabilities.authorize(checkedRequest); + if (!authorization.allowed()) { + PlatformApiResponse response = + PlatformApiResponse.error( + 403, "forbidden", "App principal lacks the required Platform API capability."); + appAuditLog.appendDecision( + checkedRequest, + authorization, + AppAuditDecision.DENIED, + response.statusCode(), + authorization.reasonCode()); + return response; + } + try { - return routeInternal(Objects.requireNonNull(request, "request")); + PlatformApiResponse response = routeInternal(checkedRequest); + appAuditLog.appendDecision( + checkedRequest, + authorization, + AppAuditDecision.ALLOWED, + response.statusCode(), + "route_completed"); + return response; } catch (PlatformApiException e) { - return PlatformApiResponse.error(e.statusCode(), e.errorCode(), e.getMessage()); + PlatformApiResponse response = + PlatformApiResponse.error(e.statusCode(), e.errorCode(), e.getMessage()); + appAuditLog.appendDecision( + checkedRequest, + authorization, + AppAuditDecision.ALLOWED, + response.statusCode(), + e.errorCode()); + return response; } catch (RuntimeException e) { LOG.log(System.Logger.Level.ERROR, "Unexpected Platform API failure", e); - return PlatformApiResponse.error(500, "internal_error", "Unexpected platform API failure."); + PlatformApiResponse response = + PlatformApiResponse.error(500, "internal_error", "Unexpected platform API failure."); + appAuditLog.appendDecision( + checkedRequest, + authorization, + AppAuditDecision.ALLOWED, + response.statusCode(), + "internal_error"); + return response; } } @@ -535,6 +587,18 @@ private PlatformApiResponse routeAppsAction( yield PlatformApiResponse.ok( envelope("logs", appsApiHandler.logs(appId, request.queryParameters()))); } + case "permissions" -> { + if (!"GET".equals(method)) { + yield methodNotAllowed("GET", GET_ONLY_MESSAGE); + } + yield PlatformApiResponse.ok(envelope("permissions", appsApiHandler.permissions(appId))); + } + case "audit" -> { + if (!"GET".equals(method)) { + yield methodNotAllowed("GET", GET_ONLY_MESSAGE); + } + yield PlatformApiResponse.ok(envelope("audit", appsApiHandler.audit(appId))); + } case "start" -> { if (!"POST".equals(method)) { yield methodNotAllowed("POST", POST_ONLY_MESSAGE); diff --git a/platform-api/src/main/java/network/crypta/platform/api/apps/AppsApiHandler.java b/platform-api/src/main/java/network/crypta/platform/api/apps/AppsApiHandler.java index eeb433eaea..8b2233e15b 100644 --- a/platform-api/src/main/java/network/crypta/platform/api/apps/AppsApiHandler.java +++ b/platform-api/src/main/java/network/crypta/platform/api/apps/AppsApiHandler.java @@ -10,6 +10,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import network.crypta.platform.api.AppAuditEvent; +import network.crypta.platform.api.AppAuditLog; import network.crypta.platform.api.PlatformApiException; import network.crypta.platform.api.PlatformApiParameters; import network.crypta.platform.apphost.AppBundleVerificationException; @@ -55,6 +57,8 @@ public final class AppsApiHandler { private static final String APP_NOT_INSTALLED_PREFIX = "app is not installed: "; private static final String CANNOT_UPDATE_RUNNING_APP_PREFIX = "cannot update a running app: "; private static final String FIELD_APP_ID = "appId"; + private static final String FIELD_PERMISSIONS = "permissions"; + private static final String FIELD_RECENT_DENIED_COUNT = "recentDeniedCount"; private static final String FIELD_RUNNING = "running"; private static final String FIELD_STARTED_AT = "startedAt"; private static final String MAX_BYTES_POSITIVE_INTEGER_MESSAGE = @@ -65,6 +69,9 @@ public final class AppsApiHandler { /** Detached AppHost core used for app lifecycle and inventory operations. */ private final AppHost appHost; + /** Shared bounded audit log for app-originated Platform API decisions. */ + private final AppAuditLog auditLog; + /** * Creates an app-management handler backed by the supplied AppHost. * @@ -76,7 +83,18 @@ public final class AppsApiHandler { * @param appHost detached AppHost core used for app lifecycle operations */ public AppsApiHandler(AppHost appHost) { + this(appHost, new AppAuditLog()); + } + + /** + * Creates an app-management handler backed by the supplied AppHost and audit log. + * + * @param appHost detached AppHost core used for app lifecycle operations + * @param auditLog bounded process-local app audit log + */ + public AppsApiHandler(AppHost appHost, AppAuditLog auditLog) { this.appHost = Objects.requireNonNull(appHost, "appHost"); + this.auditLog = Objects.requireNonNull(auditLog, "auditLog"); } /** @@ -160,6 +178,35 @@ public Map logs(String appId, Map> queryPar } } + /** + * Returns the declared permissions for one installed app. + * + * @param appId stable application identifier extracted from the request path + * @return JSON-compatible permissions summary + */ + public Map permissions(String appId) { + String normalizedAppId = normalizeAppId(appId); + InstalledAppSnapshot installed = requireInstalled(normalizedAppId); + LinkedHashMap json = LinkedHashMap.newLinkedHashMap(4); + json.put(FIELD_APP_ID, normalizedAppId); + json.put(FIELD_PERMISSIONS, installed.manifest().permissions()); + json.put(FIELD_RUNNING, appHost.status(normalizedAppId).isPresent()); + json.put(FIELD_RECENT_DENIED_COUNT, auditLog.deniedCountForApp(normalizedAppId)); + return json; + } + + /** + * Returns recent bounded audit entries for one installed app. + * + * @param appId stable application identifier extracted from the request path + * @return JSON-compatible audit summary + */ + public Map audit(String appId) { + String normalizedAppId = normalizeAppId(appId); + requireInstalled(normalizedAppId); + return auditSummary(normalizedAppId); + } + /** * Installs one staged app bundle and returns the installed summary. * @@ -446,21 +493,23 @@ private static AppManifest parseManifest(Path stagedDir) { * @param running running snapshot when the app is live, or {@code null} * @return ordered JSON-compatible summary map */ - private static Map summarize( + private Map summarize( AppManifest manifest, boolean installed, RunningAppSnapshot running) { - LinkedHashMap json = LinkedHashMap.newLinkedHashMap(12); + LinkedHashMap json = LinkedHashMap.newLinkedHashMap(14); json.put(FIELD_APP_ID, manifest.appId()); json.put("name", manifest.appName()); json.put("version", manifest.appVersion()); json.put("uiMode", manifest.uiMode().manifestValue()); json.put("uiEntry", manifest.uiEntry()); json.put("uiUrl", AppUiPaths.uiUrl(manifest)); - json.put("permissions", manifest.permissions()); + json.put(FIELD_PERMISSIONS, manifest.permissions()); json.put("quota", quota(manifest)); json.put("installed", installed); json.put(FIELD_RUNNING, running != null); json.put("pid", running == null ? null : running.pid()); json.put(FIELD_STARTED_AT, running == null ? null : running.startedAt().toString()); + json.put(FIELD_RECENT_DENIED_COUNT, auditLog.deniedCountForApp(manifest.appId())); + json.put("audit", auditSummary(manifest.appId())); return json; } @@ -517,20 +566,46 @@ private static String instantText(Instant instant) { * @param appId normalized application identifier * @return ordered JSON-compatible summary map with unknown manifest fields set to {@code null} */ - private static Map summarizeUnknown(String appId, boolean installed) { - LinkedHashMap json = LinkedHashMap.newLinkedHashMap(12); + private Map summarizeUnknown(String appId, boolean installed) { + LinkedHashMap json = LinkedHashMap.newLinkedHashMap(14); json.put(FIELD_APP_ID, appId); json.put("name", null); json.put("version", null); json.put("uiMode", "none"); json.put("uiEntry", null); json.put("uiUrl", null); - json.put("permissions", List.of()); + json.put(FIELD_PERMISSIONS, List.of()); json.put("quota", unknownQuota()); json.put("installed", installed); json.put(FIELD_RUNNING, false); json.put("pid", null); json.put(FIELD_STARTED_AT, null); + json.put(FIELD_RECENT_DENIED_COUNT, auditLog.deniedCountForApp(appId)); + json.put("audit", auditSummary(appId)); + return json; + } + + private Map auditSummary(String appId) { + List recent = auditLog.recentForApp(appId, AppAuditLog.DEFAULT_APP_EVENT_LIMIT); + LinkedHashMap json = LinkedHashMap.newLinkedHashMap(4); + json.put(FIELD_APP_ID, appId); + json.put("boundedEventLimit", AppAuditLog.DEFAULT_APP_EVENT_LIMIT); + json.put(FIELD_RECENT_DENIED_COUNT, auditLog.deniedCountForApp(appId)); + json.put("events", recent.stream().map(AppsApiHandler::summarizeAuditEvent).toList()); + return json; + } + + private static Map summarizeAuditEvent(AppAuditEvent event) { + LinkedHashMap json = LinkedHashMap.newLinkedHashMap(9); + json.put("timestamp", event.timestamp().toString()); + json.put(FIELD_APP_ID, event.appId()); + json.put("method", event.method()); + json.put("endpointFamily", event.endpointFamily()); + json.put("action", event.action()); + json.put("requiredCapabilities", event.requiredCapabilities()); + json.put("decision", event.decision().name()); + json.put("statusCode", event.statusCode()); + json.put("reasonCode", event.reasonCode()); return json; } diff --git a/platform-api/src/test/java/network/crypta/platform/api/AppAuditLogTest.java b/platform-api/src/test/java/network/crypta/platform/api/AppAuditLogTest.java new file mode 100644 index 0000000000..1ca701b8bf --- /dev/null +++ b/platform-api/src/test/java/network/crypta/platform/api/AppAuditLogTest.java @@ -0,0 +1,95 @@ +package network.crypta.platform.api; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SuppressWarnings("java:S100") +class AppAuditLogTest { + @Test + void append_whenCapacityExceeded_expectOldestEventsDropped() { + AppAuditLog log = + new AppAuditLog(2, Clock.fixed(Instant.parse("2026-04-27T00:00:00Z"), ZoneOffset.UTC)); + + log.append(event("demo-app", "queue.read", AppAuditDecision.ALLOWED, 200)); + log.append(event("demo-app", "queue.write", AppAuditDecision.DENIED, 403)); + log.append(event("demo-app", "alerts.read", AppAuditDecision.ALLOWED, 200)); + + List recent = log.recentForApp("demo-app", 10); + assertEquals(2, recent.size()); + assertEquals("alerts.read", recent.get(0).action()); + assertEquals("queue.write", recent.get(1).action()); + assertEquals(1L, log.deniedCountForApp("demo-app")); + } + + @Test + void constructor_whenCapacityIsNotPositive_expectIllegalArgumentException() { + Clock clock = Clock.fixed(Instant.parse("2026-04-27T00:00:00Z"), ZoneOffset.UTC); + + assertThrows(IllegalArgumentException.class, () -> new AppAuditLog(0, clock)); + assertThrows(IllegalArgumentException.class, () -> new AppAuditLog(-1, clock)); + } + + @Test + void recentForApp_whenLimitIsNonPositive_expectEmptyResult() { + AppAuditLog log = + new AppAuditLog(3, Clock.fixed(Instant.parse("2026-04-27T00:00:00Z"), ZoneOffset.UTC)); + log.append(event("demo-app", "queue.read", AppAuditDecision.ALLOWED, 200)); + + assertTrue(log.recentForApp("demo-app", 0).isEmpty()); + assertTrue(log.recentForApp("demo-app", -1).isEmpty()); + } + + @Test + void recentForApp_whenMultipleAppsRecorded_expectNewestMatchingEventsOnly() { + AppAuditLog log = + new AppAuditLog(4, Clock.fixed(Instant.parse("2026-04-27T00:00:00Z"), ZoneOffset.UTC)); + log.append(event("demo-app", "queue.read", AppAuditDecision.ALLOWED, 200)); + log.append(event("other-app", "alerts.read", AppAuditDecision.DENIED, 403)); + log.append(event("demo-app", "node.read", AppAuditDecision.ALLOWED, 200)); + + List recent = log.recentForApp("demo-app", 1); + + assertEquals(1, recent.size()); + assertEquals("node.read", recent.getFirst().action()); + assertEquals(0L, log.deniedCountForApp("demo-app")); + } + + @Test + void appendDecision_whenRequestIsHostOperator_expectNoAuditEvent() { + AppAuditLog log = + new AppAuditLog(3, Clock.fixed(Instant.parse("2026-04-27T00:00:00Z"), ZoneOffset.UTC)); + PlatformApiRequest request = + new PlatformApiRequest("GET", List.of("node", "greeting"), java.util.Map.of()); + + log.appendDecision( + request, + PlatformApiAuthorizationDecision.hostAllowed(), + AppAuditDecision.ALLOWED, + 200, + "route_completed"); + + assertEquals(0, log.size()); + assertTrue(log.recentForApp("demo-app", 10).isEmpty()); + } + + private static AppAuditEvent event( + String appId, String action, AppAuditDecision decision, int statusCode) { + return new AppAuditEvent( + Instant.parse("2026-04-27T00:00:00Z"), + appId, + "GET", + "queue", + action, + List.of("queue.read"), + decision, + statusCode, + decision.name().toLowerCase(java.util.Locale.ROOT)); + } +} diff --git a/platform-api/src/test/java/network/crypta/platform/api/PlatformApiCapabilitiesTest.java b/platform-api/src/test/java/network/crypta/platform/api/PlatformApiCapabilitiesTest.java new file mode 100644 index 0000000000..553bdbed49 --- /dev/null +++ b/platform-api/src/test/java/network/crypta/platform/api/PlatformApiCapabilitiesTest.java @@ -0,0 +1,259 @@ +package network.crypta.platform.api; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SuppressWarnings("java:S100") +class PlatformApiCapabilitiesTest { + @Test + void authorize_whenHostOperatorRequest_expectAllowedWithoutCapabilities() { + PlatformApiAuthorizationDecision decision = + PlatformApiCapabilities.authorize( + new PlatformApiRequest("POST", List.of("config", "persist"), Map.of())); + + assertTrue(decision.allowed()); + assertEquals("host_operator", decision.reasonCode()); + } + + @Test + void authorize_whenAppHasReadCapability_expectRepresentativeReadAllowed() { + PlatformApiAuthorizationDecision decision = + PlatformApiCapabilities.authorize( + appRequest("GET", List.of("queue"), List.of("queue.read"))); + + assertTrue(decision.allowed()); + assertEquals(List.of("queue.read"), decision.action().requiredCapabilities()); + } + + @Test + void authorize_whenAppHasRequiredCapabilities_expectAllowed() { + for (RouteCase routeCase : allowedRoutes()) { + PlatformApiAuthorizationDecision decision = + PlatformApiCapabilities.authorize( + appRequest(routeCase.method(), routeCase.segments(), routeCase.capabilities())); + + assertTrue(decision.allowed(), routeCase.actionLabel()); + assertEquals("capability_present", decision.reasonCode(), routeCase.actionLabel()); + assertEquals(routeCase.endpointFamily(), decision.action().endpointFamily()); + assertEquals(routeCase.actionLabel(), decision.action().label()); + assertEquals(routeCase.capabilities(), decision.action().requiredCapabilities()); + } + } + + @Test + void authorize_whenAppInsertLacksContentInsert_expectDenied() { + PlatformApiAuthorizationDecision decision = + PlatformApiCapabilities.authorize( + appRequest("POST", List.of("queue", "inserts", "file"), List.of("queue.write"))); + + assertFalse(decision.allowed()); + assertEquals("missing_capability", decision.reasonCode()); + assertEquals( + List.of("content.insert", "queue.write"), decision.action().requiredCapabilities()); + } + + @Test + void authorize_whenAppHitsUnmappedRoute_expectDeniedByDefault() { + PlatformApiAuthorizationDecision decision = + PlatformApiCapabilities.authorize( + appRequest("POST", List.of("node", "greeting"), List.of("node.read"))); + + assertFalse(decision.allowed()); + assertEquals("unmapped_route", decision.reasonCode()); + } + + @Test + void authorize_whenAppRouteIsUnmapped_expectDeniedByDefault() { + for (UnmappedRouteCase routeCase : unmappedAppRoutes()) { + PlatformApiAuthorizationDecision decision = + PlatformApiCapabilities.authorize( + appRequest( + routeCase.method(), + routeCase.segments(), + List.of("apps.manage", "node.read", "queue.write"))); + + assertFalse(decision.allowed(), routeCase.segments().toString()); + assertEquals("unmapped_route", decision.reasonCode(), routeCase.segments().toString()); + assertEquals("unmapped", decision.action().requiredCapabilities().getFirst()); + } + } + + private static List allowedRoutes() { + return List.of( + route("GET", List.of("node", "greeting"), "node", "node.read", "node.read"), + route("GET", List.of("node", "reference"), "node", "node.read", "node.read"), + route( + "GET", + List.of("connectivity"), + "connectivity", + "connectivity.read", + "connectivity.read"), + route("GET", List.of("queue"), "queue", "queue.read", "queue.read"), + route("GET", List.of("queue", "count"), "queue", "queue.read", "queue.read"), + route("GET", List.of("queue", "keys"), "queue", "queue.read", "queue.read"), + route( + "POST", + List.of("queue", "downloads"), + "queue", + "queue.downloads.create", + "queue.write"), + route( + "POST", + List.of("queue", "inserts", "file"), + "queue", + "queue.inserts.file", + "content.insert", + "queue.write"), + route( + "POST", + List.of("queue", "requests", "remove"), + "queue", + "queue.requests.remove", + "queue.write"), + route( + "POST", + List.of("queue", "cleanup", "uploads"), + "queue", + "queue.cleanup.uploads", + "queue.write"), + route("GET", List.of("peers"), "peers", "peers.read", "peers.read"), + route("GET", List.of("peers", "peer-123"), "peers", "peers.read", "peers.read"), + route("POST", List.of("peers", "add"), "peers", "peers.add", "peers.write"), + route( + "POST", + List.of("peers", "peer-123", "settings"), + "peers", + "peers.settings", + "peers.write"), + route("GET", List.of("config"), "config", "config.read", "config.read"), + route("POST", List.of("config", "overrides"), "config", "config.overrides", "config.write"), + route( + "GET", List.of("security-levels"), "security-levels", "security.read", "security.read"), + route( + "GET", + List.of("security-levels", "network-warning"), + "security-levels", + "security.network-warning", + "security.read"), + route( + "POST", + List.of("security-levels", "network"), + "security-levels", + "security.network", + "security.write"), + route("GET", List.of("updates", "core"), "updates", "updates.read", "updates.read"), + route( + "POST", + List.of("updates", "core", "download"), + "updates", + "updates.core.download", + "updates.write"), + route("GET", List.of("wizard", "first-time"), "wizard", "wizard.read", "wizard.read"), + route( + "POST", + List.of("wizard", "first-time", "apply"), + "wizard", + "wizard.first-time.apply", + "wizard.write"), + route("GET", List.of("alerts"), "alerts", "alerts.read", "alerts.read"), + route( + "POST", List.of("alerts", "42", "dismiss"), "alerts", "alerts.dismiss", "alerts.write"), + route("GET", List.of("diagnostics"), "diagnostics", "diagnostics.read", "diagnostics.read"), + route("GET", List.of("apps"), "apps", "apps.read", "apps.read"), + route("GET", List.of("apps", "alpha"), "apps", "apps.read", "apps.read"), + route("GET", List.of("apps", "alpha", "runtime"), "apps", "apps.read", "apps.read"), + route("GET", List.of("apps", "alpha", "logs"), "apps", "apps.read", "apps.read"), + route("GET", List.of("apps", "alpha", "permissions"), "apps", "apps.read", "apps.read"), + route("GET", List.of("apps", "alpha", "audit"), "apps", "apps.read", "apps.read"), + route("POST", List.of("apps", "install"), "apps", "apps.install", "apps.manage"), + route("DELETE", List.of("apps", "alpha"), "apps", "apps.uninstall", "apps.manage"), + route("GET", List.of("app-catalogs"), "app-catalogs", "catalogs.read", "catalogs.read"), + route( + "GET", + List.of("app-catalogs", "default", "apps"), + "app-catalogs", + "catalogs.read", + "catalogs.read"), + route( + "GET", + List.of("app-catalogs", "default", "apps", "alpha"), + "app-catalogs", + "catalogs.read", + "catalogs.read"), + route( + "POST", + List.of("app-catalogs", "add"), + "app-catalogs", + "catalogs.add", + "catalogs.manage"), + route( + "POST", + List.of("app-catalogs", "default", "apps", "alpha", "install"), + "app-catalogs", + "catalogs.apps.install", + "catalogs.manage"), + route( + "DELETE", + List.of("app-catalogs", "default"), + "app-catalogs", + "catalogs.remove", + "catalogs.manage")); + } + + private static List unmappedAppRoutes() { + return List.of( + new UnmappedRouteCase("POST", List.of("node", "greeting")), + new UnmappedRouteCase("GET", List.of("node", "unknown")), + new UnmappedRouteCase("GET", List.of("connectivity", "status")), + new UnmappedRouteCase("GET", List.of("diagnostics", "heap")), + new UnmappedRouteCase("PUT", List.of("queue", "requests", "remove")), + new UnmappedRouteCase("GET", List.of("queue", "downloads")), + new UnmappedRouteCase("GET", List.of("queue", "requests", "remove")), + new UnmappedRouteCase("POST", List.of("queue", "inserts", "unknown")), + new UnmappedRouteCase("GET", List.of("peers", "peer-123", "settings")), + new UnmappedRouteCase("GET", List.of("config", "overrides")), + new UnmappedRouteCase("GET", List.of("config", "persist")), + new UnmappedRouteCase("GET", List.of("security-levels", "network")), + new UnmappedRouteCase("GET", List.of("updates", "core", "download")), + new UnmappedRouteCase("GET", List.of("wizard", "first-time", "apply")), + new UnmappedRouteCase("GET", List.of("alerts", "42", "dismiss")), + new UnmappedRouteCase("GET", List.of("apps", "alpha", "start")), + new UnmappedRouteCase("GET", List.of("apps", "alpha", "unknown")), + new UnmappedRouteCase("POST", List.of("apps", "alpha", "logs")), + new UnmappedRouteCase("GET", List.of("app-catalogs", "default")), + new UnmappedRouteCase("GET", List.of("app-catalogs", "default", "refresh")), + new UnmappedRouteCase( + "GET", List.of("app-catalogs", "default", "apps", "alpha", "install")), + new UnmappedRouteCase( + "DELETE", List.of("app-catalogs", "default", "apps", "alpha", "install"))); + } + + private static RouteCase route( + String method, + List segments, + String endpointFamily, + String actionLabel, + String... capabilities) { + return new RouteCase(method, segments, endpointFamily, actionLabel, List.of(capabilities)); + } + + private static PlatformApiRequest appRequest( + String method, List segments, List permissions) { + return new PlatformApiRequest( + method, segments, Map.of(), PlatformApiPrincipal.appToken("demo-app", permissions)); + } + + private record RouteCase( + String method, + List segments, + String endpointFamily, + String actionLabel, + List capabilities) {} + + private record UnmappedRouteCase(String method, List segments) {} +} diff --git a/platform-api/src/test/java/network/crypta/platform/api/PlatformApiRequestTest.java b/platform-api/src/test/java/network/crypta/platform/api/PlatformApiRequestTest.java new file mode 100644 index 0000000000..1181988723 --- /dev/null +++ b/platform-api/src/test/java/network/crypta/platform/api/PlatformApiRequestTest.java @@ -0,0 +1,118 @@ +package network.crypta.platform.api; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SuppressWarnings("java:S100") +class PlatformApiRequestTest { + @Test + void constructor_whenPrincipalOmitted_expectHostOperatorPrincipal() { + PlatformApiRequest request = + new PlatformApiRequest("get", List.of("node", "greeting"), Map.of()); + + assertEquals("GET", request.method()); + assertEquals(PlatformApiPrincipalType.HOST_OPERATOR, request.principal().type()); + assertEquals(PlatformApiAuthSource.HOST_LOCAL, request.principal().authSource()); + assertTrue(request.principal().permissions().isEmpty()); + } + + @Test + void constructor_whenAppPrincipalProvided_expectSortedImmutablePermissions() { + PlatformApiRequest request = + new PlatformApiRequest( + "GET", + List.of("queue"), + Map.of(), + PlatformApiPrincipal.appToken("demo-app", List.of("queue.write", "queue.read"))); + + assertEquals(PlatformApiPrincipalType.APP, request.principal().type()); + assertEquals(PlatformApiAuthSource.APP_TOKEN, request.principal().authSource()); + assertEquals("demo-app", request.principal().appId()); + assertEquals(List.of("queue.read", "queue.write"), request.principal().permissions()); + } + + @Test + void constructor_whenMutableInputsChange_expectRequestKeepsImmutableCopies() { + ArrayList segments = new ArrayList<>(List.of("queue")); + ArrayList values = new ArrayList<>(List.of("downloads")); + LinkedHashMap> query = LinkedHashMap.newLinkedHashMap(1); + query.put("view", values); + + PlatformApiRequest request = new PlatformApiRequest("get", segments, query); + segments.add("mutated"); + values.add("mutated"); + query.put("later", List.of("value")); + + assertEquals(List.of("queue"), request.pathSegments()); + assertEquals(Map.of("view", List.of("downloads")), request.queryParameters()); + Map> copiedQuery = request.queryParameters(); + List newValue = List.of("value"); + assertThrows(UnsupportedOperationException.class, () -> copiedQuery.put("new", newValue)); + } + + @Test + void constructor_whenAppPrincipalHasInvalidShape_expectIllegalArgumentException() { + List permissions = List.of("queue.read"); + + assertThrows( + IllegalArgumentException.class, + () -> + new PlatformApiPrincipal( + PlatformApiPrincipalType.APP, + PlatformApiAuthSource.HOST_LOCAL, + "demo-app", + permissions)); + assertThrows( + IllegalArgumentException.class, + () -> + new PlatformApiPrincipal( + PlatformApiPrincipalType.APP, PlatformApiAuthSource.APP_TOKEN, " ", permissions)); + } + + @Test + void constructor_whenHostPrincipalCarriesAppFields_expectIllegalArgumentException() { + List noPermissions = List.of(); + List permissions = List.of("queue.read"); + + assertThrows( + IllegalArgumentException.class, + () -> + new PlatformApiPrincipal( + PlatformApiPrincipalType.HOST_OPERATOR, + PlatformApiAuthSource.HOST_LOCAL, + "demo-app", + noPermissions)); + assertThrows( + IllegalArgumentException.class, + () -> + new PlatformApiPrincipal( + PlatformApiPrincipalType.HOST_OPERATOR, + PlatformApiAuthSource.HOST_LOCAL, + null, + permissions)); + assertThrows( + IllegalArgumentException.class, + () -> + new PlatformApiPrincipal( + PlatformApiPrincipalType.HOST_OPERATOR, + PlatformApiAuthSource.APP_TOKEN, + null, + noPermissions)); + } + + @Test + void appToken_whenPermissionIsBlank_expectIllegalArgumentException() { + List permissions = List.of("queue.read", " "); + + assertThrows( + IllegalArgumentException.class, + () -> PlatformApiPrincipal.appToken("demo-app", permissions)); + } +} diff --git a/platform-apphost/src/main/java/network/crypta/platform/apphost/AppHost.java b/platform-apphost/src/main/java/network/crypta/platform/apphost/AppHost.java index 0a81437985..cc52f66b15 100644 --- a/platform-apphost/src/main/java/network/crypta/platform/apphost/AppHost.java +++ b/platform-apphost/src/main/java/network/crypta/platform/apphost/AppHost.java @@ -155,6 +155,22 @@ InstalledAppSnapshot updateFromDirectory(String appId, Path stagedAppDirectory) */ List listRunning(); + /** + * Authenticates a bearer launch token against the host's current live app state. + * + *

Blank, unknown, stopped, and stale launch tokens must not authenticate. Successful + * authentication returns a token-free principal containing the running app id and a sorted, + * immutable copy of its manifest permissions. Implementations must never return or expose the raw + * token through the principal. + * + * @param token opaque launch token presented by an app process + * @return token-free principal when the token belongs to a currently running app, or {@link + * Optional#empty()} otherwise + */ + default Optional authenticateLaunchToken(String token) { + return Optional.empty(); + } + /** * Returns token-free process runtime status for one installed app. * diff --git a/platform-apphost/src/main/java/network/crypta/platform/apphost/AppTokenPrincipal.java b/platform-apphost/src/main/java/network/crypta/platform/apphost/AppTokenPrincipal.java new file mode 100644 index 0000000000..0e48d5e76f --- /dev/null +++ b/platform-apphost/src/main/java/network/crypta/platform/apphost/AppTokenPrincipal.java @@ -0,0 +1,46 @@ +package network.crypta.platform.apphost; + +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +/** + * Token-free identity derived from a live AppHost launch token. + * + *

The principal intentionally carries only stable app identity and manifest-declared + * permissions. It never stores the raw launch token, so diagnostic strings and downstream + * authorization decisions cannot accidentally expose bearer credentials. + * + *

{@link AppHost#authenticateLaunchToken(String)} returns this value after it confirms that a + * presented token belongs to a currently running app. The Platform API bridge can then convert it + * into its transport-neutral principal model without learning AppHost internals or returning the + * token to JSON surfaces. Permissions are sorted to make authorization decisions, logs, and tests + * deterministic. + * + * @param appId stable application identifier for the currently running app + * @param permissions immutable manifest permission list sorted lexicographically + */ +public record AppTokenPrincipal(String appId, List permissions) { + /** + * Creates a principal with a normalized, immutable permission view. + * + *

The constructor rejects blank app ids and copies the permission stream into sorted immutable + * order. It does not validate permission names against the Platform API capability matrix; that + * remains the router's responsibility because manifests may contain permissions for other + * app-host features. + * + * @param appId stable application identifier for the authenticated app process + * @param permissions manifest permission list captured from the running app snapshot + * @throws IllegalArgumentException if {@code appId} is blank + * @throws NullPointerException if {@code appId}, {@code permissions}, or a permission element is + * {@code null} + */ + public AppTokenPrincipal { + Objects.requireNonNull(appId, "appId"); + Objects.requireNonNull(permissions, "permissions"); + if (appId.isBlank()) { + throw new IllegalArgumentException("appId must not be blank"); + } + permissions = permissions.stream().sorted(Comparator.naturalOrder()).toList(); + } +} diff --git a/platform-apphost/src/main/java/network/crypta/platform/apphost/runtime/LocalProcessAppHost.java b/platform-apphost/src/main/java/network/crypta/platform/apphost/runtime/LocalProcessAppHost.java index e55d594b34..f43f45ad53 100644 --- a/platform-apphost/src/main/java/network/crypta/platform/apphost/runtime/LocalProcessAppHost.java +++ b/platform-apphost/src/main/java/network/crypta/platform/apphost/runtime/LocalProcessAppHost.java @@ -45,6 +45,7 @@ import network.crypta.platform.apphost.AppProcessLogSnapshot; import network.crypta.platform.apphost.AppRuntimeState; import network.crypta.platform.apphost.AppRuntimeStatusSnapshot; +import network.crypta.platform.apphost.AppTokenPrincipal; import network.crypta.platform.apphost.InstalledAppPaths; import network.crypta.platform.apphost.InstalledAppSnapshot; import network.crypta.platform.apphost.OwnerOnlyFilePermissions; @@ -658,6 +659,30 @@ public synchronized List listRunning() { return List.copyOf(running); } + /** + * Authenticates a launch token against refreshed live runtime state. + * + * @param token opaque launch token presented by an app process + * @return token-free principal when the token belongs to a currently running app + */ + @Override + public synchronized Optional authenticateLaunchToken(String token) { + if (token == null || token.isBlank()) { + return Optional.empty(); + } + List appIds = new ArrayList<>(runningApps.keySet()); + appIds.sort(String::compareTo); + for (String appId : appIds) { + RunningProcess runningProcess = liveRunningProcess(appId); + if (runningProcess != null && token.equals(runningProcess.snapshot().token())) { + RunningAppSnapshot snapshot = runningProcess.snapshot(); + return Optional.of( + new AppTokenPrincipal(snapshot.appId(), snapshot.manifest().permissions())); + } + } + return Optional.empty(); + } + /** * Returns token-free runtime status for one installed app. * diff --git a/platform-apphost/src/test/java/network/crypta/platform/apphost/runtime/LocalProcessAppHostTest.java b/platform-apphost/src/test/java/network/crypta/platform/apphost/runtime/LocalProcessAppHostTest.java index ca90c558fc..1ddf8ea728 100644 --- a/platform-apphost/src/test/java/network/crypta/platform/apphost/runtime/LocalProcessAppHostTest.java +++ b/platform-apphost/src/test/java/network/crypta/platform/apphost/runtime/LocalProcessAppHostTest.java @@ -53,6 +53,7 @@ import network.crypta.platform.apphost.AppProcessLogSnapshot; import network.crypta.platform.apphost.AppRuntimeState; import network.crypta.platform.apphost.AppRuntimeStatusSnapshot; +import network.crypta.platform.apphost.AppTokenPrincipal; import network.crypta.platform.apphost.InstalledAppPaths; import network.crypta.platform.apphost.InstalledAppSnapshot; import network.crypta.platform.apphost.OwnerOnlyFilePermissions; @@ -1078,6 +1079,91 @@ void lifecycle_whenInstalledScriptRuns_expectEnvTokenStopAndUninstall() throws I assertTrue(host.listInstalled().isEmpty()); } + @Test + void authenticateLaunchToken_whenTokenBelongsToRunningApp_expectTokenFreePrincipal() + throws IOException { + AppHost host = allowUnsignedHost(); + host.installFromDirectory(stageInstalledApp(RUNNER_APP_ID)); + RunningAppSnapshot running = host.start(RUNNER_APP_ID); + + try { + AppTokenPrincipal principal = host.authenticateLaunchToken(running.token()).orElseThrow(); + + assertEquals(RUNNER_APP_ID, principal.appId()); + assertEquals( + List.of(FILE_READ_PERMISSION, NETWORK_ACCESS_PERMISSION), principal.permissions()); + assertFalse(principal.toString().contains(running.token())); + } finally { + host.stop(RUNNER_APP_ID); + } + } + + @Test + void authenticateLaunchToken_whenTokenIsBlankOrUnknown_expectEmpty() throws IOException { + AppHost host = allowUnsignedHost(); + host.installFromDirectory(stageInstalledApp(RUNNER_APP_ID)); + RunningAppSnapshot running = host.start(RUNNER_APP_ID); + + try { + assertTrue(host.authenticateLaunchToken("").isEmpty()); + assertTrue(host.authenticateLaunchToken(" ").isEmpty()); + assertTrue(host.authenticateLaunchToken(null).isEmpty()); + assertTrue(host.authenticateLaunchToken(running.token() + "-unknown").isEmpty()); + } finally { + host.stop(RUNNER_APP_ID); + } + } + + @Test + void authenticateLaunchToken_whenAppStops_expectOldTokenFails() throws IOException { + AppHost host = allowUnsignedHost(); + host.installFromDirectory(stageInstalledApp(RUNNER_APP_ID)); + RunningAppSnapshot running = host.start(RUNNER_APP_ID); + + assertTrue(host.authenticateLaunchToken(running.token()).isPresent()); + assertTrue(host.stop(RUNNER_APP_ID)); + + assertTrue(host.authenticateLaunchToken(running.token()).isEmpty()); + } + + @Test + void authenticateLaunchToken_whenAppRestarts_expectOldTokenFailsAndNewTokenSucceeds() + throws IOException { + AppHost host = allowUnsignedHost(); + host.installFromDirectory(stageInstalledApp(RUNNER_APP_ID)); + RunningAppSnapshot firstRun = host.start(RUNNER_APP_ID); + assertTrue(host.stop(RUNNER_APP_ID)); + + RunningAppSnapshot secondRun = host.start(RUNNER_APP_ID); + + try { + assertNotEquals(firstRun.token(), secondRun.token()); + assertTrue(host.authenticateLaunchToken(firstRun.token()).isEmpty()); + assertEquals( + RUNNER_APP_ID, host.authenticateLaunchToken(secondRun.token()).orElseThrow().appId()); + } finally { + host.stop(RUNNER_APP_ID); + } + } + + @Test + void authenticateLaunchToken_whenReturningPermissions_expectImmutableSortedManifestPermissions() + throws IOException { + AppHost host = allowUnsignedHost(); + host.installFromDirectory(stageInstalledApp(RUNNER_APP_ID)); + RunningAppSnapshot running = host.start(RUNNER_APP_ID); + + try { + AppTokenPrincipal principal = host.authenticateLaunchToken(running.token()).orElseThrow(); + List permissions = principal.permissions(); + + assertEquals(List.of(FILE_READ_PERMISSION, NETWORK_ACCESS_PERMISSION), permissions); + assertThrows(UnsupportedOperationException.class, () -> permissions.add("extra")); + } finally { + host.stop(RUNNER_APP_ID); + } + } + @Test void start_whenAppRuns_expectWorkingDirectoryRuntimeStatusAndRedactedLog() throws IOException { AppEnv appEnv = new AppEnv(); diff --git a/platform-web-shell/src/main/resources/network/crypta/platform/webshell/static/web-shell.css b/platform-web-shell/src/main/resources/network/crypta/platform/webshell/static/web-shell.css index 44c2da16a4..7e27f0f34a 100644 --- a/platform-web-shell/src/main/resources/network/crypta/platform/webshell/static/web-shell.css +++ b/platform-web-shell/src/main/resources/network/crypta/platform/webshell/static/web-shell.css @@ -590,6 +590,46 @@ a { overflow-wrap: anywhere; } +.permission-list { + margin: 0; + padding-left: 20px; + color: var(--shell-text); +} + +.permission-list li { + overflow-wrap: anywhere; +} + +.app-audit-list { + display: grid; + gap: 10px; +} + +.app-audit-event { + display: grid; + gap: 8px; + padding: 10px 12px; + border-left: 3px solid rgba(83, 214, 160, 0.72); + background: rgba(255, 255, 255, 0.025); +} + +.app-audit-event.is-denied { + border-left-color: rgba(255, 104, 104, 0.82); +} + +.app-audit-event-header { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.app-audit-action { + color: var(--shell-text); + font-weight: 700; + overflow-wrap: anywhere; +} + .diagnostics-lines, .diagnostics-export { margin: 0; diff --git a/platform-web-shell/src/main/resources/network/crypta/platform/webshell/static/web-shell.js b/platform-web-shell/src/main/resources/network/crypta/platform/webshell/static/web-shell.js index c06957426d..b3023acd1e 100644 --- a/platform-web-shell/src/main/resources/network/crypta/platform/webshell/static/web-shell.js +++ b/platform-web-shell/src/main/resources/network/crypta/platform/webshell/static/web-shell.js @@ -895,6 +895,12 @@ : null; } + function appAuditPath(appId) { + return typeof appId === "string" && appId.length > 0 + ? `apps/${encodeURIComponent(appId)}/audit` + : null; + } + function catalogMutationPath(catalogId, appId, action) { if (typeof catalogId !== "string" || catalogId.length === 0) { return null; @@ -991,6 +997,7 @@ card.className = "app-card"; const runtime = app && app.runtime && typeof app.runtime === "object" ? app.runtime : null; const logs = app && app.logs && typeof app.logs === "object" ? app.logs : null; + const audit = app && app.audit && typeof app.audit === "object" ? app.audit : null; const runtimeError = typeof app.runtimeError === "string" ? app.runtimeError : ""; const runtimeState = runtime && typeof runtime.state === "string" && runtime.state ? runtime.state : app.running ? "RUNNING" : "STOPPED"; @@ -1015,6 +1022,14 @@ if (app.uiUrl || app.uiEntry) { pills.append(createPill(app.uiMode === "static" ? "Static UI" : "UI")); } + const deniedCount = audit && typeof audit.recentDeniedCount === "number" + ? audit.recentDeniedCount + : typeof app.recentDeniedCount === "number" + ? app.recentDeniedCount + : 0; + if (deniedCount > 0) { + pills.append(createPill(`${deniedCount} denied`, "is-error")); + } header.append(heading, pills); card.append(header); @@ -1033,10 +1048,13 @@ ["Last exit code", runtime && runtime.lastExitCode != null ? scalar(runtime.lastExitCode) : "Unavailable"], ["Last exit at", runtime ? formatIsoTimestamp(runtime.lastExitAt) : "Unavailable"], ["Restart attempts", runtime ? scalar(runtime.currentRestartAttempt) : "Unavailable"], + ["Denied app calls", scalar(deniedCount)], ["Process log", logs && logs.available ? `${scalar(logs.sizeBytes)} bytes` : "Unavailable"], ["Runtime detail", runtimeError || (runtime ? "Available" : "Unavailable")], ]; card.append(definitionList(entries)); + card.append(appPermissionsDetailsNode(app)); + card.append(appAuditDetailsNode(audit, app.auditError)); const logDetails = appLogDetailsNode(logs, app.logsError); if (logDetails) { card.append(logDetails); @@ -1067,6 +1085,71 @@ return card; } + function appPermissionsDetailsNode(app) { + const details = document.createElement("details"); + details.className = "json-details"; + const summary = document.createElement("summary"); + summary.textContent = "Declared permissions"; + details.append(summary); + const permissions = Array.isArray(app.permissions) ? app.permissions : []; + if (!permissions.length) { + details.append(text("p", "empty-state", "No manifest permissions declared.")); + return details; + } + const list = document.createElement("ul"); + list.className = "permission-list"; + permissions.forEach((permission) => { + list.append(text("li", "", scalar(permission))); + }); + details.append(list); + return details; + } + + function appAuditDetailsNode(audit, auditError) { + const details = document.createElement("details"); + details.className = "json-details"; + const summary = document.createElement("summary"); + summary.textContent = "Recent app audit"; + details.append(summary); + if (auditError) { + details.append(text("p", "error-state", auditError)); + return details; + } + const events = audit && Array.isArray(audit.events) ? audit.events : []; + if (!events.length) { + details.append(text("p", "empty-state", "No app-originated Platform API calls recorded.")); + return details; + } + const list = document.createElement("div"); + list.className = "app-audit-list"; + events.slice(0, 8).forEach((event) => { + const safeEvent = event && typeof event === "object" ? event : {}; + const item = document.createElement("div"); + const denied = safeEvent.decision === "DENIED"; + item.className = "app-audit-event" + (denied ? " is-denied" : ""); + const header = document.createElement("div"); + header.className = "app-audit-event-header"; + header.append( + createPill(scalar(safeEvent.decision), denied ? "is-error" : "is-success"), + text("span", "app-audit-action", scalar(safeEvent.action)), + ); + item.append(header); + item.append( + definitionList([ + ["Time", formatIsoTimestamp(safeEvent.timestamp)], + ["Method", scalar(safeEvent.method)], + ["Endpoint", scalar(safeEvent.endpointFamily)], + ["Required", formatPermissions(safeEvent.requiredCapabilities)], + ["Status", scalar(safeEvent.statusCode)], + ["Reason", scalar(safeEvent.reasonCode)], + ]), + ); + list.append(item); + }); + details.append(list); + return details; + } + function appLogDetailsNode(logs, logsError) { const details = document.createElement("details"); details.className = "json-details"; @@ -2233,10 +2316,13 @@ } const runtimePath = appRuntimePath(app.appId); const logsPath = appLogsPath(app.appId, 65536); + const auditPath = appAuditPath(app.appId); let runtime = null; let runtimeError = ""; let logs = null; let logsError = ""; + let audit = app.audit && typeof app.audit === "object" ? app.audit : null; + let auditError = ""; try { const runtimeSnapshot = runtimePath ? await loadOptionalJson(apiUrl(runtimePath)) : null; runtime = @@ -2257,7 +2343,17 @@ logsError = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error"; } - return { ...app, runtime, runtimeError, logs, logsError }; + try { + const auditSnapshot = auditPath ? await loadOptionalJson(apiUrl(auditPath)) : null; + audit = + auditSnapshot && auditSnapshot.audit && typeof auditSnapshot.audit === "object" + ? auditSnapshot.audit + : auditSnapshot || audit; + } catch (error) { + auditError = + error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error"; + } + return { ...app, runtime, runtimeError, logs, logsError, audit, auditError }; } async function loadCatalogApps(catalog) { diff --git a/platform-web-shell/src/test/java/network/crypta/platform/webshell/WebShellResourcesTest.java b/platform-web-shell/src/test/java/network/crypta/platform/webshell/WebShellResourcesTest.java index 4dee6cdaf8..b01a1f48c2 100644 --- a/platform-web-shell/src/test/java/network/crypta/platform/webshell/WebShellResourcesTest.java +++ b/platform-web-shell/src/test/java/network/crypta/platform/webshell/WebShellResourcesTest.java @@ -100,6 +100,8 @@ void readText_whenStylesheetResourceRequested_expectAlertTextPreservesNewlines() assertTrue(stylesheet.contains(".app-card-list {")); assertTrue(stylesheet.contains(".app-card-actions {")); assertTrue(stylesheet.contains(".app-log-tail {")); + assertTrue(stylesheet.contains(".permission-list {")); + assertTrue(stylesheet.contains(".app-audit-event.is-denied {")); assertTrue(stylesheet.contains(".catalog-app-card {")); assertTrue(stylesheet.contains(".publisher-forms {")); assertTrue(stylesheet.contains(".publisher-result-actions {")); @@ -326,6 +328,7 @@ private static void assertAppsMarkersPresent(String script) { assertTrue(script.contains("function appUiHref(app)")); assertTrue(script.contains("function appRuntimePath(appId)")); assertTrue(script.contains("function appLogsPath(appId, maxBytes)")); + assertTrue(script.contains("function appAuditPath(appId)")); assertTrue(script.contains("async function loadAppRuntimeDetails(app)")); assertTrue(script.contains("const explicitHref = normalizeAppUiEntryHref(app.uiUrl);")); assertTrue( @@ -339,6 +342,9 @@ private static void assertAppsMarkersPresent(String script) { assertTrue(script.contains("runtimeStoppable ? \"Stop\" : \"Start\"")); assertTrue(script.contains("const uninstallForm = runtimeStoppable")); assertTrue(script.contains("Runtime log tail")); + assertTrue(script.contains("Declared permissions")); + assertTrue(script.contains("Recent app audit")); + assertTrue(script.contains("function appAuditDetailsNode(audit, auditError)")); assertTrue(script.contains("app-log-tail")); assertTrue(script.contains("function renderApps(data)")); assertTrue(script.contains("function renderCatalogs(catalogs, catalogError)")); @@ -365,6 +371,7 @@ private static void assertAppsMarkersPresent(String script) { assertTrue(script.contains("return `apps/${encodedAppId}`;")); assertTrue(script.contains("`apps/${encodeURIComponent(appId)}/runtime`")); assertTrue(script.contains("`apps/${encodeURIComponent(appId)}/logs?maxBytes=")); + assertTrue(script.contains("`apps/${encodeURIComponent(appId)}/audit`")); assertTrue(script.contains("return `app-catalogs/${encodedCatalogId}/refresh`;")); assertTrue( script.contains( diff --git a/src/test/java/network/crypta/clients/http/PlatformApiToadletTest.java b/src/test/java/network/crypta/clients/http/PlatformApiToadletTest.java index 6443775f00..0ba528c91b 100644 --- a/src/test/java/network/crypta/clients/http/PlatformApiToadletTest.java +++ b/src/test/java/network/crypta/clients/http/PlatformApiToadletTest.java @@ -5,10 +5,13 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import network.crypta.platform.api.PlatformApiRequest; import network.crypta.platform.api.PlatformApiResponse; import network.crypta.platform.api.PlatformApiRouter; +import network.crypta.platform.apphost.AppHost; +import network.crypta.platform.apphost.AppTokenPrincipal; import network.crypta.support.MultiValueTable; import network.crypta.support.SimpleReadOnlyArrayBucket; import network.crypta.support.api.Bucket; @@ -23,8 +26,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -34,6 +39,7 @@ class PlatformApiToadletTest { @Mock private PlatformApiRouter router; + @Mock private AppHost appHost; @Mock private ToadletContext ctx; @Mock private HTTPRequest request; @Mock private Bucket requestBody; @@ -43,6 +49,207 @@ class PlatformApiToadletTest { @BeforeEach void setUp() { toadlet = new PlatformApiToadlet(router); + lenient().when(request.getHeader("x-crypta-app-token")).thenReturn(null); + lenient().when(request.getHeader("authorization")).thenReturn(null); + } + + @Test + void handleMethodGET_whenAppTokenHeaderAuthenticates_routesAppPrincipalRequest() + throws Exception { + toadlet = new PlatformApiToadlet(router, appHost); + when(request.getHeader("x-crypta-app-token")).thenReturn("secret-token"); + when(appHost.authenticateLaunchToken("secret-token")) + .thenReturn(Optional.of(new AppTokenPrincipal("alpha", List.of("queue.read")))); + when(request.getParameterNames()).thenReturn(List.of()); + when(router.route(any(PlatformApiRequest.class))) + .thenReturn(PlatformApiResponse.ok(Map.of("ok", true))); + + toadlet.handleMethodGET(URI.create("http://localhost/api/v1/queue"), request, ctx); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PlatformApiRequest.class); + verify(router).route(captor.capture()); + assertEquals("alpha", captor.getValue().principal().appId()); + assertEquals(List.of("queue.read"), captor.getValue().principal().permissions()); + verify(ctx, never()).isAllowedFullAccess(); + } + + @Test + void handleMethodGET_whenBearerTokenAuthenticates_routesAppPrincipalRequest() throws Exception { + toadlet = new PlatformApiToadlet(router, appHost); + when(request.getHeader("authorization")).thenReturn("Bearer secret-token"); + when(appHost.authenticateLaunchToken("secret-token")) + .thenReturn(Optional.of(new AppTokenPrincipal("alpha", List.of("node.read")))); + when(request.getParameterNames()).thenReturn(List.of()); + when(router.route(any(PlatformApiRequest.class))) + .thenReturn(PlatformApiResponse.ok(Map.of("ok", true))); + + toadlet.handleMethodGET(URI.create("http://localhost/api/v1/node/greeting"), request, ctx); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PlatformApiRequest.class); + verify(router).route(captor.capture()); + assertEquals("alpha", captor.getValue().principal().appId()); + assertEquals(List.of("node.read"), captor.getValue().principal().permissions()); + } + + @Test + void handleMethodGET_whenBearerTokenDoesNotAuthenticate_routesHostOperatorRequest() + throws Exception { + toadlet = new PlatformApiToadlet(router, appHost); + when(request.getHeader("authorization")).thenReturn("Bearer proxy-token"); + when(appHost.authenticateLaunchToken("proxy-token")).thenReturn(Optional.empty()); + when(ctx.isAllowedFullAccess()).thenReturn(true); + when(request.getParameterNames()).thenReturn(List.of()); + when(router.route(any(PlatformApiRequest.class))) + .thenReturn(PlatformApiResponse.ok(Map.of("ok", true))); + + toadlet.handleMethodGET(URI.create("http://localhost/api/v1/node/greeting"), request, ctx); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PlatformApiRequest.class); + verify(router).route(captor.capture()); + assertEquals("HOST_OPERATOR", captor.getValue().principal().type().name()); + verify(ctx).isAllowedFullAccess(); + } + + @Test + void handleMethodGET_whenBearerTokenLookupFails_expectJson500WithoutHostFallback() + throws Exception { + toadlet = new PlatformApiToadlet(router, appHost); + when(request.getHeader("authorization")).thenReturn("Bearer proxy-token"); + when(appHost.authenticateLaunchToken("proxy-token")) + .thenThrow(new IllegalStateException("apphost state unavailable")); + + toadlet.handleMethodGET(URI.create("http://localhost/api/v1/node/greeting"), request, ctx); + + verifyNoInteractions(router); + verify(ctx, never()).isAllowedFullAccess(); + ReplyHeadersCapture replyHeaders = captureReplyHeaders(); + assertEquals(500, replyHeaders.statusCode()); + assertEquals("Internal Server Error", replyHeaders.reasonPhrase()); + BodyWriteCapture bodyWrite = captureBodyWrite(); + assertEquals( + "{\"error\":{\"code\":\"internal_error\",\"message\":\"Unexpected platform API" + + " failure.\"}}", + bodyWrite.bodyText()); + assertFalse(bodyWrite.bodyText().contains("proxy-token")); + } + + @Test + void handleMethodGET_whenTokenOnlyAppearsInQuery_routesHostRequestWithoutTokenAuth() + throws Exception { + toadlet = new PlatformApiToadlet(router, appHost); + when(ctx.isAllowedFullAccess()).thenReturn(true); + when(request.getParameterNames()).thenReturn(List.of("token")); + when(request.getMultipleParam("token")).thenReturn(new String[] {"secret-token"}); + when(router.route(any(PlatformApiRequest.class))) + .thenReturn(PlatformApiResponse.ok(Map.of("ok", true))); + + toadlet.handleMethodGET( + URI.create("http://localhost/api/v1/node/greeting?token=secret-token"), request, ctx); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PlatformApiRequest.class); + verify(router).route(captor.capture()); + assertEquals("HOST_OPERATOR", captor.getValue().principal().type().name()); + assertEquals(Map.of("token", List.of("secret-token")), captor.getValue().queryParameters()); + verify(appHost, never()).authenticateLaunchToken(any()); + } + + @Test + void handleMethodGET_whenAppTokenInvalid_expect401WithoutTokenLeak() throws Exception { + toadlet = new PlatformApiToadlet(router, appHost); + when(request.getHeader("x-crypta-app-token")).thenReturn("secret-token"); + when(appHost.authenticateLaunchToken("secret-token")).thenReturn(Optional.empty()); + + toadlet.handleMethodGET(URI.create("http://localhost/api/v1/node/greeting"), request, ctx); + + verifyNoInteractions(router); + ReplyHeadersCapture replyHeaders = captureReplyHeaders(); + assertEquals(401, replyHeaders.statusCode()); + assertEquals("Unauthorized", replyHeaders.reasonPhrase()); + BodyWriteCapture bodyWrite = captureBodyWrite(); + assertEquals( + "{\"error\":{\"code\":\"invalid_app_token\",\"message\":\"Invalid app token.\"}}", + bodyWrite.bodyText()); + assertFalse(bodyWrite.bodyText().contains("secret-token")); + } + + @Test + void handleMethodGET_whenAppTokenAuthenticationFails_expectJson500WithoutRouting() + throws Exception { + toadlet = new PlatformApiToadlet(router, appHost); + when(request.getHeader("x-crypta-app-token")).thenReturn("secret-token"); + when(appHost.authenticateLaunchToken("secret-token")) + .thenThrow(new IllegalStateException("apphost unavailable")); + + toadlet.handleMethodGET(URI.create("http://localhost/api/v1/node/greeting"), request, ctx); + + verifyNoInteractions(router); + verify(ctx, never()).isAllowedFullAccess(); + ReplyHeadersCapture replyHeaders = captureReplyHeaders(); + assertEquals(500, replyHeaders.statusCode()); + assertEquals("Internal Server Error", replyHeaders.reasonPhrase()); + BodyWriteCapture bodyWrite = captureBodyWrite(); + assertEquals( + "{\"error\":{\"code\":\"internal_error\",\"message\":\"Unexpected platform API" + + " failure.\"}}", + bodyWrite.bodyText()); + assertFalse(bodyWrite.bodyText().contains("secret-token")); + } + + @Test + void handleMethodGET_whenAppTokenProvidedButAppHostUnavailable_expect401WithoutRouting() + throws Exception { + when(request.getHeader("x-crypta-app-token")).thenReturn("secret-token"); + + toadlet.handleMethodGET(URI.create("http://localhost/api/v1/node/greeting"), request, ctx); + + verifyNoInteractions(router); + verify(ctx, never()).isAllowedFullAccess(); + ReplyHeadersCapture replyHeaders = captureReplyHeaders(); + assertEquals(401, replyHeaders.statusCode()); + assertEquals("Unauthorized", replyHeaders.reasonPhrase()); + BodyWriteCapture bodyWrite = captureBodyWrite(); + assertEquals( + "{\"error\":{\"code\":\"invalid_app_token\",\"message\":\"App token authentication is" + + " unavailable.\"}}", + bodyWrite.bodyText()); + assertFalse(bodyWrite.bodyText().contains("secret-token")); + } + + @Test + void handleMethodGET_whenAppTokenHeaderBlank_expect401WithoutRouting() throws Exception { + toadlet = new PlatformApiToadlet(router, appHost); + when(request.getHeader("x-crypta-app-token")).thenReturn(" "); + when(appHost.authenticateLaunchToken("")).thenReturn(Optional.empty()); + + toadlet.handleMethodGET(URI.create("http://localhost/api/v1/node/greeting"), request, ctx); + + verifyNoInteractions(router); + verify(ctx, never()).isAllowedFullAccess(); + verify(appHost).authenticateLaunchToken(""); + ReplyHeadersCapture replyHeaders = captureReplyHeaders(); + assertEquals(401, replyHeaders.statusCode()); + BodyWriteCapture bodyWrite = captureBodyWrite(); + assertEquals( + "{\"error\":{\"code\":\"invalid_app_token\",\"message\":\"Invalid app token.\"}}", + bodyWrite.bodyText()); + } + + @Test + void handleMethodGET_whenAuthorizationHeaderIsMalformedBearer_expectHostOperatorRequest() + throws Exception { + toadlet = new PlatformApiToadlet(router, appHost); + when(request.getHeader("authorization")).thenReturn("BearerToken secret-token"); + when(ctx.isAllowedFullAccess()).thenReturn(true); + when(request.getParameterNames()).thenReturn(List.of()); + when(router.route(any(PlatformApiRequest.class))) + .thenReturn(PlatformApiResponse.ok(Map.of("ok", true))); + + toadlet.handleMethodGET(URI.create("http://localhost/api/v1/node/greeting"), request, ctx); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PlatformApiRequest.class); + verify(router).route(captor.capture()); + assertEquals("HOST_OPERATOR", captor.getValue().principal().type().name()); + verify(appHost, never()).authenticateLaunchToken(any()); } @Test @@ -476,6 +683,7 @@ void handleMethodPOST_whenProtectedMutationPasswordMissing_expectJson403WithoutR toadlet.handleMethodPOST(URI.create(requestUri), request, ctx); verifyNoInteractions(router); + verify(ctx, never()).checkFormPassword(request, URI.create(requestUri).getPath()); assertForbiddenBody(); } diff --git a/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java b/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java index 720ec7d01b..eb01fa2dde 100644 --- a/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java +++ b/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java @@ -2056,6 +2056,201 @@ void route_whenAppsDescribeRequested_expectAppEnvelopeAndMergedRunningState() th response.body()); } + @Test + void route_whenAppPermissionsRequested_expectDeclaredPermissionsAndDeniedCount() + throws Exception { + AppAuditLog auditLog = new AppAuditLog(); + PlatformApiRouter auditedRouter = + new PlatformApiRouter(runtimePorts, appHost, null, null, auditLog); + auditLog.append( + new AppAuditEvent( + STARTED_AT, + APP_ID, + "POST", + "queue", + "queue.requests.remove", + List.of("queue.write"), + AppAuditDecision.DENIED, + 403, + "missing_capability")); + when(appHost.describe(APP_ID)).thenReturn(Optional.of(installedSnapshot())); + when(appHost.status(APP_ID)).thenReturn(Optional.empty()); + + PlatformApiResponse response = + auditedRouter.route(request("GET", List.of("apps", APP_ID, "permissions"), Map.of())); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("\"permissions\":[\"network.access\",\"file.read\"]")); + assertTrue(response.body().contains("\"recentDeniedCount\":1")); + assertFalse(response.body().contains("token-")); + } + + @Test + void route_whenAppAuditRequested_expectRecentTokenFreeAuditEntries() throws Exception { + AppAuditLog auditLog = new AppAuditLog(); + PlatformApiRouter auditedRouter = + new PlatformApiRouter(runtimePorts, appHost, null, null, auditLog); + auditLog.append( + new AppAuditEvent( + STARTED_AT, + APP_ID, + "GET", + "node", + "node.read", + List.of("node.read"), + AppAuditDecision.ALLOWED, + 200, + "route_completed")); + when(appHost.describe(APP_ID)).thenReturn(Optional.of(installedSnapshot())); + + PlatformApiResponse response = + auditedRouter.route(request("GET", List.of("apps", APP_ID, "audit"), Map.of())); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("\"decision\":\"ALLOWED\"")); + assertTrue(response.body().contains("\"requiredCapabilities\":[\"node.read\"]")); + assertFalse(response.body().contains("token-")); + } + + @Test + void route_whenAppPrincipalHasCapability_expectDispatchAndAllowedAudit() { + AppAuditLog auditLog = new AppAuditLog(); + PlatformApiRouter auditedRouter = + new PlatformApiRouter(runtimePorts, appHost, null, null, auditLog); + when(nodeInfoPort.greeting()) + .thenReturn(new NodeGreetingSnapshot("Crypta", "1.0", 7, "abc123", true, "gzip", "en")); + + PlatformApiResponse response = + auditedRouter.route( + appRequest(List.of("node", "greeting"), Map.of(), List.of("node.read"))); + + assertEquals(200, response.statusCode()); + List events = auditLog.recentForApp(APP_ID, 10); + assertEquals(1, events.size()); + assertEquals(AppAuditDecision.ALLOWED, events.getFirst().decision()); + assertEquals("node.read", events.getFirst().action()); + } + + @Test + void route_whenAppPrincipalMissingCapability_expectForbiddenAndDeniedAudit() { + AppAuditLog auditLog = new AppAuditLog(); + PlatformApiRouter auditedRouter = + new PlatformApiRouter(runtimePorts, appHost, null, null, auditLog); + + PlatformApiResponse response = + auditedRouter.route( + appRequest(List.of("node", "greeting"), Map.of(), List.of("queue.read"))); + + assertEquals(403, response.statusCode()); + assertTrue(response.body().contains("\"code\":\"forbidden\"")); + assertFalse(response.body().contains("secret-token")); + verifyNoInteractions(nodeInfoPort); + List events = auditLog.recentForApp(APP_ID, 10); + assertEquals(1, events.size()); + assertEquals(AppAuditDecision.DENIED, events.getFirst().decision()); + assertEquals("missing_capability", events.getFirst().reasonCode()); + } + + @Test + void route_whenAppPrincipalHitsUnsupportedQueueReadShape_expectForbiddenAndDeniedAudit() { + AppAuditLog auditLog = new AppAuditLog(); + PlatformApiRouter auditedRouter = + new PlatformApiRouter(runtimePorts, appHost, null, null, auditLog); + + PlatformApiResponse response = + auditedRouter.route( + appRequest(List.of("queue", "requests", "remove"), Map.of(), List.of("queue.read"))); + + assertEquals(403, response.statusCode()); + assertTrue(response.body().contains("\"code\":\"forbidden\"")); + List events = auditLog.recentForApp(APP_ID, 10); + assertEquals(1, events.size()); + assertEquals(AppAuditDecision.DENIED, events.getFirst().decision()); + assertEquals("queue.unmapped", events.getFirst().action()); + assertEquals("unmapped_route", events.getFirst().reasonCode()); + } + + @Test + void route_whenAppPrincipalHitsUnsupportedAlertsReadShape_expectForbiddenAndDeniedAudit() { + AppAuditLog auditLog = new AppAuditLog(); + PlatformApiRouter auditedRouter = + new PlatformApiRouter(runtimePorts, appHost, null, null, auditLog); + + PlatformApiResponse response = + auditedRouter.route( + appRequest(List.of("alerts", "42", "dismiss"), Map.of(), List.of("alerts.read"))); + + assertEquals(403, response.statusCode()); + assertTrue(response.body().contains("\"code\":\"forbidden\"")); + verifyNoInteractions(alertMutationPort); + List events = auditLog.recentForApp(APP_ID, 10); + assertEquals(1, events.size()); + assertEquals(AppAuditDecision.DENIED, events.getFirst().decision()); + assertEquals("alerts.unmapped", events.getFirst().action()); + assertEquals("unmapped_route", events.getFirst().reasonCode()); + } + + @Test + void route_whenAppPrincipalHitsUnsupportedConfigReadShape_expectForbiddenAndDeniedAudit() { + AppAuditLog auditLog = new AppAuditLog(); + PlatformApiRouter auditedRouter = + new PlatformApiRouter(runtimePorts, appHost, null, null, auditLog); + + PlatformApiResponse response = + auditedRouter.route( + appRequest(List.of("config", "overrides"), Map.of(), List.of("config.read"))); + + assertEquals(403, response.statusCode()); + assertTrue(response.body().contains("\"code\":\"forbidden\"")); + verifyNoInteractions(configPort); + List events = auditLog.recentForApp(APP_ID, 10); + assertEquals(1, events.size()); + assertEquals(AppAuditDecision.DENIED, events.getFirst().decision()); + assertEquals("config.unmapped", events.getFirst().action()); + assertEquals("unmapped_route", events.getFirst().reasonCode()); + } + + @Test + void route_whenAppPrincipalHitsUnsupportedAppsReadShape_expectForbiddenAndDeniedAudit() { + AppAuditLog auditLog = new AppAuditLog(); + PlatformApiRouter auditedRouter = + new PlatformApiRouter(runtimePorts, appHost, null, null, auditLog); + + PlatformApiResponse response = + auditedRouter.route( + appRequest(List.of("apps", APP_ID, "start"), Map.of(), List.of("apps.read"))); + + assertEquals(403, response.statusCode()); + assertTrue(response.body().contains("\"code\":\"forbidden\"")); + verifyNoInteractions(appHost); + List events = auditLog.recentForApp(APP_ID, 10); + assertEquals(1, events.size()); + assertEquals(AppAuditDecision.DENIED, events.getFirst().decision()); + assertEquals("apps.unmapped", events.getFirst().action()); + assertEquals("unmapped_route", events.getFirst().reasonCode()); + } + + @Test + void route_whenAppPrincipalHitsUnsupportedCatalogsReadShape_expectForbiddenAndDeniedAudit() { + AppAuditLog auditLog = new AppAuditLog(); + AppCatalogManager catalogManager = mock(AppCatalogManager.class); + PlatformApiRouter auditedRouter = + new PlatformApiRouter(runtimePorts, appHost, catalogManager, null, auditLog); + + PlatformApiResponse response = + auditedRouter.route( + appRequest(List.of("app-catalogs", "core"), Map.of(), List.of("catalogs.read"))); + + assertEquals(403, response.statusCode()); + assertTrue(response.body().contains("\"code\":\"forbidden\"")); + verifyNoInteractions(catalogManager); + List events = auditLog.recentForApp(APP_ID, 10); + assertEquals(1, events.size()); + assertEquals(AppAuditDecision.DENIED, events.getFirst().decision()); + assertEquals("app-catalogs.unmapped", events.getFirst().action()); + assertEquals("unmapped_route", events.getFirst().reasonCode()); + } + @Test void route_whenAppRuntimeRequested_expectRuntimeEnvelopeWithoutToken() throws Exception { when(appHost.runtimeStatus(APP_ID)) @@ -2869,6 +3064,14 @@ private static PlatformApiRequest request( return new PlatformApiRequest(method, pathSegments, queryParameters); } + private static PlatformApiRequest appRequest( + List pathSegments, + Map> queryParameters, + List permissions) { + return new PlatformApiRequest( + "GET", pathSegments, queryParameters, PlatformApiPrincipal.appToken(APP_ID, permissions)); + } + @SafeVarargs private static Map orderedJson(Map.Entry... entries) { LinkedHashMap json = LinkedHashMap.newLinkedHashMap(entries.length); @@ -3033,7 +3236,7 @@ private static Map summaryFor( boolean running, Long pid, Instant startedAt) { - LinkedHashMap summary = LinkedHashMap.newLinkedHashMap(12); + LinkedHashMap summary = LinkedHashMap.newLinkedHashMap(14); summary.put("appId", appId); summary.put("name", appName); summary.put("version", appVersion); @@ -3049,11 +3252,13 @@ private static Map summaryFor( summary.put("running", running); summary.put("pid", pid); summary.put("startedAt", startedAt == null ? null : startedAt.toString()); + summary.put("recentDeniedCount", 0L); + summary.put("audit", emptyAuditSummary(appId)); return summary; } private static Map unknownSummary() { - LinkedHashMap summary = LinkedHashMap.newLinkedHashMap(12); + LinkedHashMap summary = LinkedHashMap.newLinkedHashMap(14); summary.put("appId", APP_ID); summary.put("name", null); summary.put("version", null); @@ -3069,9 +3274,20 @@ private static Map unknownSummary() { summary.put("running", false); summary.put("pid", null); summary.put("startedAt", null); + summary.put("recentDeniedCount", 0L); + summary.put("audit", emptyAuditSummary(APP_ID)); return summary; } + private static Map emptyAuditSummary(String appId) { + LinkedHashMap audit = LinkedHashMap.newLinkedHashMap(4); + audit.put("appId", appId); + audit.put("boundedEventLimit", AppAuditLog.DEFAULT_APP_EVENT_LIMIT); + audit.put("recentDeniedCount", 0L); + audit.put("events", List.of()); + return audit; + } + private static String uiMode(String appUiEntry) { if (appUiEntry == null) { return "none";