Let an owner-only app keep one door open for its own API (#415) - #416
Merged
Conversation
Hosted exposure was whole-app and binary, so an app that pairs a token-authed API with a session-authed UI had to go fully public for its SDK to work — which dropped the box login in front of the UI too. A manifest may now declare `access.public_paths`. A restricted app's route becomes one Caddy subroute: the declared paths proxy straight to the app, everything else stays gated. One route and one @id, so the insert-at-0 ordering against the catch-all is unchanged, and the proxy handler is built once and used on both branches so the per-cookie strip cannot land on one and not the other. The identity headers are now scrubbed on every hosted app route, not only where the gate runs. The gate does not run on a public path, so nothing there would overwrite a caller-supplied X-Malmo-User; the same hole already existed for a fully public app. The dashboard names the open paths instead of claiming a bare "Only me", reading them from the instance's own manifest copy so the label cannot drift from the route. The hosted lane's access boot carries the proof through real Caddy, including a bypass table that must stay gated.
Confidence Score: 4/5The PR should not merge until manifest-read failures can no longer make the dashboard conceal anonymous paths that remain active in Caddy. A previously configured carve-out remains live when reconciliation cannot reload the instance manifest, while the API suppresses that same error and the UI renders an unqualified owner-only label. Files Needing Attention: internal/api/api.go, internal/lifecycle/lifecycle.go, web-ui/src/views/settings/InstalledAppDetailSection.vue
|
| Filename | Overview |
|---|---|
| internal/api/api.go | Adds public_paths enrichment, but manifest failures still produce an empty field while an existing anonymous Caddy route can remain active. |
| internal/api/appexposure.go | Enriches the exposure mutation response with instance-manifest public paths through the shared helper. |
| internal/caddy/caddy.go | Adds path-scoped subrouting and unconditional request identity-header scrubbing while sharing the proxy handler between branches. |
| internal/lifecycle/lifecycle.go | Passes manifest access declarations into centralized hosted route construction and reconciliation. |
| internal/manifest/manifest.go | Introduces and validates the optional access.public_paths manifest field. |
| web-ui/src/views/settings/InstalledAppDetailSection.vue | Displays declared anonymous paths alongside the restricted-access label. |
Reviews (3): Last reviewed commit: "Fix the bypass table's wrong expectation..." | Re-trigger Greptile
…l GET The access label is exposure plus declared public paths together, so the toggle's echo carrying only the exposure would render "Only me" for an app with open paths until the next refetch. Both responses now go through one withPublicPaths helper.
…ailure Two things the first cloud run found. //v1/ never reaches the gate: Caddy collapses the duplicate slash and 301s to the normalized path before matching, so the app is not reached and the expectation was wrong, not the probe. The table now asserts the claim that matters — an undeclared path never reaches the app upstream anonymously — and allows the merge-redirect shape for that entry. Worse, that failure was reported green. run-cloud-tests.sh matched the guest verdict with a *PASS* glob and the reason read 'PATH GATE BYPASS', which contains PASS, so a red access boot printed 'boot access OK'. The verdict is now matched exactly. Any failure text containing bypass or password would have done this to any boot in this lane.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Hosted exposure is whole-app and binary, so an app that pairs a token-authed API with a session-authed UI had to be made fully public for its SDK to work — which dropped the box login in front of the UI too. Langfuse ships with exactly that limitation; Laminar hits it worse, because its self-hosted UI signs in any email with no password.
A manifest may now declare
access.public_paths. Arestrictedapp's route becomes one Caddysubroute: the declared paths proxy straight to the app, everything else stays gated. One route and one@id, soupsertRoute's insert-at-0 and the catch-all's evaluation order are untouched, and the proxy handler is built once and used on both branches so the #335 per-cookie strip cannot land on one and not the other.Two decisions widened the issue's shape, both deliberate:
X-Malmo-User— the app would get a brain-vouched header on one path and a forged one on another with no way to tell them apart. The same hole already existed for a fully public app: one that learned to trust the header while restricted kept trusting it the moment the owner flipped the toggle. One line closes both.GETand the toggle's echo, through onewithPublicPathshelper — because the label is built from exposure and paths together. An app that declares public paths names them on its Access control instead of claiming a bare "Only you can open it". The paths are read from the instance's manifest copy — the same file the route was built from — so the label cannot drift from the route when the catalog moves on.Spec(s) touched
docs/specs/APP_MANIFEST.md— new # E2 (the field, its two allowed shapes, and why catalog review rather than the validator is the trust boundary).docs/specs/ENVIRONMENT.md# Per-app owner-only access — the subroute shape and the unconditional scrub.docs/specs/DASHBOARD.md# Settings — the Access control must name the open paths.docs/specs/UPDATES.md# Auto-apply unless permissions expand — a new or widenedpublic_pathsentry prompts the instance owner. Specified, not built: the permission diff itself is unimplemented, and this PR does not implement it.docs/architecture.md— thelifecycleandcaddyrows.No
DECISIONS.mdentry: nothing locked flipped.What was tested
make checkequivalent green: gofmt, vet, OpenAPI freshness, and the full Go suite minuspamverifier/host-agent-real(this machine has a pre-existing cgoC.RTLD_NEXTfailure ingithub.com/msteinert/pam/v2that predates the branch — those two packages are CI-proven).make check-webgreen:npm ci,gen:api(the generated schema is committed and fresh),vue-tsc+ production build.internal/caddy(subroute shape, scrub-is-first across all three route shapes, public paths ignored without a gate), 5 ininternal/lifecycle(carve-out on install, scrub in every exposure, public app drops the paths, appliance unchanged, reconcile rebuilds the carve-out), ~20 validation cases ininternal/manifest, 2 ininternal/api(the DTO reads the instance's manifest copy).accessboot. Its test-catalog whoami now declarespublic_paths: ["/v1", "/v1/*"], and the boot asserts what no unit test can reach — declared paths answer anonymously; a forgedX-Malmo-Usernever reaches the app on the public branch; the same forgery on the gated branch is replaced by the brain's vouched value rather than passed through or dropped;malmo_forward_authis stripped on the public branch too while the app's own cookie survives; and a bypass table (/v1extra,/v1/../,//v1/,/v1/%2e%2e/,/V1x,/admin) all 302 to the box login. Requests are written raw onto the socket, so no client-side normalization softens them.Known gaps & deviations
external-sdk-needs-public-applimitation and Laminar's screening note stay accurate until amalmoos/storemanifest declares its ingestion paths.Platform gaps
None (not a catalog PR).
Definition of done
accessboot rather than by unit tests alone.docs/progress/path-scoped-app-exposure.md); progress index row appended at the bottom.internal/catalog/published.gocarriesmanifest.ymlas verbatim bytes, so the new field reaches a box unchanged and the control plane needs no change).log/slogonly.dev, PR intodev.Closes #415