From 2605f1490af578047d0396841e65cf8416baf87a Mon Sep 17 00:00:00 2001 From: Leumor <116955025+leumor@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:46:00 +0000 Subject: [PATCH 1/5] feat(platform-api): Enforce app permissions Authenticate process-originated app requests with launch tokens, authorize them against manifest permissions, and record bounded audit decisions. Expose permissions and recent app audit entries through the Apps API and Web Shell while keeping launch tokens redacted from responses, snapshots, and static UI payloads. --- .../clients/http/PlatformApiToadlet.java | 196 +++++++- docs/SECURITY.md | 19 +- docs/app-catalogs.md | 5 +- docs/app-owned-ui.md | 16 +- docs/app-permissions-and-audit.md | 118 +++++ docs/apphost-runtime-hardening.md | 18 +- docs/phase-3-platform-primacy-closeout.md | 3 +- docs/platform-api-surface.md | 15 +- .../crypta/platform/api/AppAuditDecision.java | 44 ++ .../crypta/platform/api/AppAuditEvent.java | 61 +++ .../crypta/platform/api/AppAuditLog.java | 183 +++++++ .../platform/api/PlatformApiAction.java | 93 ++++ .../platform/api/PlatformApiAuthSource.java | 42 ++ .../api/PlatformApiAuthorizationDecision.java | 92 ++++ .../platform/api/PlatformApiCapabilities.java | 476 ++++++++++++++++++ .../platform/api/PlatformApiPrincipal.java | 138 +++++ .../api/PlatformApiPrincipalType.java | 32 ++ .../platform/api/PlatformApiRequest.java | 30 +- .../platform/api/PlatformApiResponse.java | 1 + .../platform/api/PlatformApiRouter.java | 72 ++- .../platform/api/apps/AppsApiHandler.java | 87 +++- .../crypta/platform/api/AppAuditLogTest.java | 95 ++++ .../api/PlatformApiCapabilitiesTest.java | 214 ++++++++ .../platform/api/PlatformApiRequestTest.java | 110 ++++ .../crypta/platform/apphost/AppHost.java | 16 + .../platform/apphost/AppTokenPrincipal.java | 46 ++ .../apphost/runtime/LocalProcessAppHost.java | 25 + .../runtime/LocalProcessAppHostTest.java | 86 ++++ .../platform/webshell/static/web-shell.css | 40 ++ .../platform/webshell/static/web-shell.js | 98 +++- .../webshell/WebShellResourcesTest.java | 7 + .../clients/http/PlatformApiToadletTest.java | 208 ++++++++ .../platform/api/PlatformApiRouterTest.java | 120 ++++- 33 files changed, 2751 insertions(+), 55 deletions(-) create mode 100644 docs/app-permissions-and-audit.md create mode 100644 platform-api/src/main/java/network/crypta/platform/api/AppAuditDecision.java create mode 100644 platform-api/src/main/java/network/crypta/platform/api/AppAuditEvent.java create mode 100644 platform-api/src/main/java/network/crypta/platform/api/AppAuditLog.java create mode 100644 platform-api/src/main/java/network/crypta/platform/api/PlatformApiAction.java create mode 100644 platform-api/src/main/java/network/crypta/platform/api/PlatformApiAuthSource.java create mode 100644 platform-api/src/main/java/network/crypta/platform/api/PlatformApiAuthorizationDecision.java create mode 100644 platform-api/src/main/java/network/crypta/platform/api/PlatformApiCapabilities.java create mode 100644 platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipal.java create mode 100644 platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipalType.java create mode 100644 platform-api/src/test/java/network/crypta/platform/api/AppAuditLogTest.java create mode 100644 platform-api/src/test/java/network/crypta/platform/api/PlatformApiCapabilitiesTest.java create mode 100644 platform-api/src/test/java/network/crypta/platform/api/PlatformApiRequestTest.java create mode 100644 platform-apphost/src/main/java/network/crypta/platform/apphost/AppTokenPrincipal.java 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..fea0732d78 --- /dev/null +++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiCapabilities.java @@ -0,0 +1,476 @@ +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_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" -> getOnly(method, family, NODE_READ, NODE_READ); + case "connectivity" -> getOnly(method, family, CONNECTIVITY_READ, CONNECTIVITY_READ); + case "diagnostics" -> getOnly(method, family, DIAGNOSTICS_READ, DIAGNOSTICS_READ); + 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 "apps" -> appsAction(method, segments); + case FAMILY_APP_CATALOGS -> catalogsAction(method, segments); + default -> null; + }; + } + + /** + * 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)) { + 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. + * + *

Configuration reads use the broad {@link #CONFIG_READ} capability. Writes are limited to the + * explicit override and persist endpoints and require {@link #CONFIG_WRITE}; other POST 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)) { + 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 the allowed + * mutation names 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 action(FAMILY_SECURITY_LEVELS, SECURITY_READ, SECURITY_READ); + } + 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; + } + + /** + * 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)) { + 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)) { + 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 action(FAMILY_QUEUE, QUEUE_READ, QUEUE_READ); + } + 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 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)) { + 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 action("apps", APPS_READ, APPS_READ); + } + if ("DELETE".equals(method) && segments.size() == 2) { + return action("apps", "apps.uninstall", APPS_MANAGE); + } + if (!"POST".equals(method)) { + return null; + } + if (segments.size() == 2 && "install".equals(segments.get(1))) { + return action("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("apps", "apps." + segments.get(2), APPS_MANAGE); + } + 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 action(FAMILY_APP_CATALOGS, CATALOGS_READ, CATALOGS_READ); + } + 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; + } + + /** + * Builds an action only when the request method is {@code GET}. + * + *

Read-only endpoint families use this helper when every path below the family has the same + * capability. Unsupported methods return {@code null} so app principals are denied before + * dispatch rather than reaching a handler-level method 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 + * @return read action for {@code GET}, or {@code null} for other methods + */ + private static PlatformApiAction getOnly( + String method, String endpointFamily, String label, String capability) { + return "GET".equals(method) ? 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..27607b3549 --- /dev/null +++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipal.java @@ -0,0 +1,138 @@ +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"); + } + } + } + + /** + * 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..6ec3759021 --- /dev/null +++ b/platform-api/src/test/java/network/crypta/platform/api/PlatformApiCapabilitiesTest.java @@ -0,0 +1,214 @@ +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("connectivity"), + "connectivity", + "connectivity.read", + "connectivity.read"), + route("GET", List.of("queue"), "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("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( + "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("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( + "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("PUT", List.of("queue", "requests", "remove")), + new UnmappedRouteCase("POST", List.of("queue", "inserts", "unknown")), + new UnmappedRouteCase("POST", List.of("apps", "alpha", "logs")), + 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..a9a4b41df0 --- /dev/null +++ b/platform-api/src/test/java/network/crypta/platform/api/PlatformApiRequestTest.java @@ -0,0 +1,110 @@ +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)); + } + + @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..7b344d2502 100644 --- a/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java +++ b/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java @@ -2056,6 +2056,101 @@ 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_whenAppRuntimeRequested_expectRuntimeEnvelopeWithoutToken() throws Exception { when(appHost.runtimeStatus(APP_ID)) @@ -2869,6 +2964,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 +3136,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 +3152,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 +3174,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"; From b9db8be293abe4ac228e4815f5791d1337409625 Mon Sep 17 00:00:00 2001 From: Leumor <116955025+leumor@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:37:35 +0000 Subject: [PATCH 2/5] fix(platform-api): tighten app authorization routes --- .../platform/api/PlatformApiCapabilities.java | 36 ++++++++++++++++--- .../platform/api/PlatformApiPrincipal.java | 3 ++ .../api/PlatformApiCapabilitiesTest.java | 7 ++++ .../platform/api/PlatformApiRequestTest.java | 8 +++++ .../platform/api/PlatformApiRouterTest.java | 20 +++++++++++ 5 files changed, 69 insertions(+), 5 deletions(-) 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 index fea0732d78..223854bf3a 100644 --- a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiCapabilities.java +++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiCapabilities.java @@ -89,6 +89,7 @@ final class PlatformApiCapabilities { 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"; @@ -155,7 +156,7 @@ private static PlatformApiAction actionFor(PlatformApiRequest request) { case FAMILY_WIZARD -> wizardAction(method, segments); case FAMILY_QUEUE -> queueAction(method, segments); case FAMILY_PEERS -> peersAction(method, segments); - case "apps" -> appsAction(method, segments); + case FAMILY_APPS -> appsAction(method, segments); case FAMILY_APP_CATALOGS -> catalogsAction(method, segments); default -> null; }; @@ -356,22 +357,47 @@ private static PlatformApiAction peersAction(String method, List segment */ private static PlatformApiAction appsAction(String method, List segments) { if ("GET".equals(method)) { - return action("apps", APPS_READ, APPS_READ); + return appsReadAction(segments); } if ("DELETE".equals(method) && segments.size() == 2) { - return action("apps", "apps.uninstall", APPS_MANAGE); + 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("apps", "apps.install", APPS_MANAGE); + 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("apps", "apps." + segments.get(2), APPS_MANAGE); + 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; } 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 index 27607b3549..8c51a8ce69 100644 --- a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipal.java +++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiPrincipal.java @@ -59,6 +59,9 @@ public record PlatformApiPrincipal( 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"); + } } } 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 index 6ec3759021..76e46303e4 100644 --- a/platform-api/src/test/java/network/crypta/platform/api/PlatformApiCapabilitiesTest.java +++ b/platform-api/src/test/java/network/crypta/platform/api/PlatformApiCapabilitiesTest.java @@ -155,6 +155,11 @@ private static List allowedRoutes() { "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"), @@ -183,6 +188,8 @@ private static List unmappedAppRoutes() { new UnmappedRouteCase("POST", List.of("node", "greeting")), new UnmappedRouteCase("PUT", List.of("queue", "requests", "remove")), new UnmappedRouteCase("POST", List.of("queue", "inserts", "unknown")), + 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( "DELETE", List.of("app-catalogs", "default", "apps", "alpha", "install"))); 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 index a9a4b41df0..1181988723 100644 --- a/platform-api/src/test/java/network/crypta/platform/api/PlatformApiRequestTest.java +++ b/platform-api/src/test/java/network/crypta/platform/api/PlatformApiRequestTest.java @@ -97,6 +97,14 @@ void constructor_whenHostPrincipalCarriesAppFields_expectIllegalArgumentExceptio PlatformApiAuthSource.HOST_LOCAL, null, permissions)); + assertThrows( + IllegalArgumentException.class, + () -> + new PlatformApiPrincipal( + PlatformApiPrincipalType.HOST_OPERATOR, + PlatformApiAuthSource.APP_TOKEN, + null, + noPermissions)); } @Test diff --git a/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java b/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java index 7b344d2502..9b00a71d5d 100644 --- a/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java +++ b/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java @@ -2151,6 +2151,26 @@ void route_whenAppPrincipalMissingCapability_expectForbiddenAndDeniedAudit() { assertEquals("missing_capability", 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_whenAppRuntimeRequested_expectRuntimeEnvelopeWithoutToken() throws Exception { when(appHost.runtimeStatus(APP_ID)) From e3e2cafad48ab81341c368b410653819a75847fb Mon Sep 17 00:00:00 2001 From: Leumor <116955025+leumor@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:52:19 +0000 Subject: [PATCH 3/5] fix(platform-api): restrict catalog read authorization --- .../platform/api/PlatformApiCapabilities.java | 23 ++++++++++++++++++- .../api/PlatformApiCapabilitiesTest.java | 16 +++++++++++++ .../platform/api/PlatformApiRouterTest.java | 21 +++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) 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 index 223854bf3a..dec5c1023c 100644 --- a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiCapabilities.java +++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiCapabilities.java @@ -416,7 +416,7 @@ private static PlatformApiAction appsReadAction(List segments) { */ private static PlatformApiAction catalogsAction(String method, List segments) { if ("GET".equals(method)) { - return action(FAMILY_APP_CATALOGS, CATALOGS_READ, CATALOGS_READ); + return catalogsReadAction(segments); } if ("DELETE".equals(method) && segments.size() == 2) { return action(FAMILY_APP_CATALOGS, "catalogs.remove", CATALOGS_MANAGE); @@ -438,6 +438,27 @@ private static PlatformApiAction catalogsAction(String method, List segm 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}. * 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 index 76e46303e4..27a910985d 100644 --- a/platform-api/src/test/java/network/crypta/platform/api/PlatformApiCapabilitiesTest.java +++ b/platform-api/src/test/java/network/crypta/platform/api/PlatformApiCapabilitiesTest.java @@ -163,6 +163,18 @@ private static List allowedRoutes() { 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"), @@ -191,6 +203,10 @@ private static List unmappedAppRoutes() { 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"))); } diff --git a/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java b/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java index 9b00a71d5d..d07010a3d2 100644 --- a/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java +++ b/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java @@ -2171,6 +2171,27 @@ void route_whenAppPrincipalHitsUnsupportedAppsReadShape_expectForbiddenAndDenied 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)) From e0f32c871207c87c9ef110bb770037e1cdf5f6f6 Mon Sep 17 00:00:00 2001 From: Leumor <116955025+leumor@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:59:37 +0000 Subject: [PATCH 4/5] fix(platform-api): restrict queue read authorization --- .../platform/api/PlatformApiCapabilities.java | 21 ++++++++++++++++++- .../api/PlatformApiCapabilitiesTest.java | 4 ++++ .../platform/api/PlatformApiRouterTest.java | 19 +++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) 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 index dec5c1023c..971805fd4d 100644 --- a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiCapabilities.java +++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiCapabilities.java @@ -289,7 +289,7 @@ private static PlatformApiAction wizardAction(String method, List segmen */ private static PlatformApiAction queueAction(String method, List segments) { if ("GET".equals(method)) { - return action(FAMILY_QUEUE, QUEUE_READ, QUEUE_READ); + return queueReadAction(segments); } if (!"POST".equals(method)) { return null; @@ -315,6 +315,25 @@ private static PlatformApiAction queueAction(String method, List segment 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. * 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 index 27a910985d..e211241329 100644 --- a/platform-api/src/test/java/network/crypta/platform/api/PlatformApiCapabilitiesTest.java +++ b/platform-api/src/test/java/network/crypta/platform/api/PlatformApiCapabilitiesTest.java @@ -93,6 +93,8 @@ private static List allowedRoutes() { "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"), @@ -199,6 +201,8 @@ private static List unmappedAppRoutes() { return List.of( new UnmappedRouteCase("POST", List.of("node", "greeting")), 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("apps", "alpha", "start")), new UnmappedRouteCase("GET", List.of("apps", "alpha", "unknown")), diff --git a/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java b/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java index d07010a3d2..67469e9b7a 100644 --- a/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java +++ b/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java @@ -2151,6 +2151,25 @@ void route_whenAppPrincipalMissingCapability_expectForbiddenAndDeniedAudit() { 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_whenAppPrincipalHitsUnsupportedAppsReadShape_expectForbiddenAndDeniedAudit() { AppAuditLog auditLog = new AppAuditLog(); From c82ec5beef8302a35e4eb977ae8326fed18bdd70 Mon Sep 17 00:00:00 2001 From: Leumor <116955025+leumor@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:10:07 +0000 Subject: [PATCH 5/5] fix(platform-api): narrow read route authorization --- .../platform/api/PlatformApiCapabilities.java | 75 +++++++++++++------ .../api/PlatformApiCapabilitiesTest.java | 18 +++++ .../platform/api/PlatformApiRouterTest.java | 40 ++++++++++ 3 files changed, 112 insertions(+), 21 deletions(-) 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 index 971805fd4d..2a2b5090a7 100644 --- a/platform-api/src/main/java/network/crypta/platform/api/PlatformApiCapabilities.java +++ b/platform-api/src/main/java/network/crypta/platform/api/PlatformApiCapabilities.java @@ -146,9 +146,11 @@ private static PlatformApiAction actionFor(PlatformApiRequest request) { String family = segments.getFirst(); String method = request.method(); return switch (family) { - case "node" -> getOnly(method, family, NODE_READ, NODE_READ); - case "connectivity" -> getOnly(method, family, CONNECTIVITY_READ, CONNECTIVITY_READ); - case "diagnostics" -> getOnly(method, family, DIAGNOSTICS_READ, DIAGNOSTICS_READ); + 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); @@ -162,6 +164,26 @@ private static PlatformApiAction actionFor(PlatformApiRequest request) { }; } + /** + * 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. * @@ -173,7 +195,7 @@ private static PlatformApiAction actionFor(PlatformApiRequest request) { * @return matched alerts action, or {@code null} for unsupported alert routes */ private static PlatformApiAction alertsAction(String method, List segments) { - if ("GET".equals(method)) { + 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))) { @@ -185,8 +207,8 @@ private static PlatformApiAction alertsAction(String method, List segmen /** * Maps configuration read and write routes. * - *

Configuration reads use the broad {@link #CONFIG_READ} capability. Writes are limited to the - * explicit override and persist endpoints and require {@link #CONFIG_WRITE}; other POST shapes + *

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 @@ -194,7 +216,7 @@ private static PlatformApiAction alertsAction(String method, List segmen * @return matched configuration action, or {@code null} for unsupported config routes */ private static PlatformApiAction configAction(String method, List segments) { - if ("GET".equals(method)) { + if ("GET".equals(method) && segments.size() == 1) { return action(FAMILY_CONFIG, CONFIG_READ, CONFIG_READ); } if ("POST".equals(method) @@ -209,8 +231,8 @@ private static PlatformApiAction configAction(String method, List segmen * 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 the allowed - * mutation names explicit avoids granting future security routes by accident. + * 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 @@ -218,7 +240,7 @@ private static PlatformApiAction configAction(String method, List segmen */ private static PlatformApiAction securityAction(String method, List segments) { if ("GET".equals(method)) { - return action(FAMILY_SECURITY_LEVELS, SECURITY_READ, SECURITY_READ); + return securityReadAction(segments); } if ("POST".equals(method) && segments.size() == 2 @@ -228,6 +250,16 @@ private static PlatformApiAction securityAction(String method, List segm 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. * @@ -240,7 +272,7 @@ private static PlatformApiAction securityAction(String method, List segm * @return matched updater action, or {@code null} for unsupported updater routes */ private static PlatformApiAction updatesAction(String method, List segments) { - if ("GET".equals(method)) { + if ("GET".equals(method) && segments.size() == 2 && "core".equals(segments.get(1))) { return action(FAMILY_UPDATES, UPDATES_READ, UPDATES_READ); } if ("POST".equals(method) @@ -264,7 +296,7 @@ private static PlatformApiAction updatesAction(String method, List segme * @return matched wizard action, or {@code null} for unsupported wizard routes */ private static PlatformApiAction wizardAction(String method, List segments) { - if ("GET".equals(method)) { + 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) @@ -346,7 +378,7 @@ private static PlatformApiAction queueReadAction(List segments) { * @return matched peer action, or {@code null} for unsupported peer routes */ private static PlatformApiAction peersAction(String method, List segments) { - if ("GET".equals(method)) { + 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))) { @@ -479,21 +511,22 @@ private static boolean isCatalogAppRead(List segments) { } /** - * Builds an action only when the request method is {@code GET}. + * Builds an action only when the request method is {@code GET} and the route shape is supported. * - *

Read-only endpoint families use this helper when every path below the family has the same - * capability. Unsupported methods return {@code null} so app principals are denied before - * dispatch rather than reaching a handler-level method check. + *

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 - * @return read action for {@code GET}, or {@code null} for other methods + * @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 getOnly( - String method, String endpointFamily, String label, String capability) { - return "GET".equals(method) ? action(endpointFamily, label, capability) : null; + private static PlatformApiAction exactGet( + String method, String endpointFamily, String label, String capability, boolean routeMatches) { + return "GET".equals(method) && routeMatches ? action(endpointFamily, label, capability) : null; } /** 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 index e211241329..553bdbed49 100644 --- a/platform-api/src/test/java/network/crypta/platform/api/PlatformApiCapabilitiesTest.java +++ b/platform-api/src/test/java/network/crypta/platform/api/PlatformApiCapabilitiesTest.java @@ -86,6 +86,7 @@ void authorize_whenAppRouteIsUnmapped_expectDeniedByDefault() { 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"), @@ -121,6 +122,7 @@ private static List allowedRoutes() { "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", @@ -132,6 +134,12 @@ private static List allowedRoutes() { 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"), @@ -200,10 +208,20 @@ private static List allowedRoutes() { 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")), diff --git a/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java b/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java index 67469e9b7a..eb01fa2dde 100644 --- a/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java +++ b/src/test/java/network/crypta/platform/api/PlatformApiRouterTest.java @@ -2170,6 +2170,46 @@ void route_whenAppPrincipalHitsUnsupportedQueueReadShape_expectForbiddenAndDenie 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();