From f2577a6903df94fffce2016ed0d640bf5cd4184a Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Mon, 17 Aug 2026 16:42:09 +0200 Subject: [PATCH 01/27] docs(canvas): rewrite sharing spec for authenticated viewer --- docs/spec/canvas-sharing.md | 633 ++++++++++++++---------------------- 1 file changed, 247 insertions(+), 386 deletions(-) diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index a34b77ce..2a08983d 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -2,414 +2,275 @@ ## Goals -- One-click **Share** of a focused canvas doc to an **unguessable URL** any recipient can open in a - plain browser — **no login** — that renders the doc's HTML+JS read-only with agent-interactivity - neutralized. -- **Recipient-only secrecy:** the link is a per-doc capability (a leaked link exposes only that one - doc) and **auto-expires** (7 days — Azure's maximum for a keyless user delegation SAS). -- **Hide the ugly URL:** copy a **rich titled hyperlink** to the clipboard so the raw URL length is - cosmetically irrelevant when pasted into chat/mail. +- One-click Share of a focused canvas doc to a clean, unguessable URL that a recipient opens in a + plain browser after signing in with Microsoft Entra. No SAS, account key, or other bearer + credential is ever generated or returned to the recipient. +- Real authorization, not just secrecy: the URL's opaque segment narrows which document a + signed-in identity can reach, but Entra sign-in (and any enterprise-app assignment) is what + actually gates access. A leaked link alone is not sufficient to view the document. +- A bounded, view-time-enforced lifetime: a document stops being viewable at its configured expiry + even if the underlying blob has not yet been swept up by storage lifecycle cleanup. +- Contained interactivity: the recipient still sees the document's live HTML/JS rather than a + static/PDF downgrade, but that script runs in a sandbox that cannot reach the viewer's + authenticated origin, read its cookies, or exfiltrate over the network. +- A rich, titled hyperlink on the clipboard so paste targets (chat, mail, docs) show a meaningful + link regardless of the URL's actual length or shape. ## Expected Behavior ### Share action -- A **Share** button appears in the canvas tab bar next to Archive, for **`AgentDoc` docs only** - (a `SystemView` like the beads dashboard is server-generated and not shareable). -- Clicking it: static-exports the focused doc → uploads it to Azure Blob Storage → mints a per-doc - read-only SAS URL → writes a **rich link + plain URL** to the clipboard → shows a success banner - (`Shared — link copied`). On failure it shows the existing dismissible error banner. -- **The button shows progress and refuses re-entry while a share is in flight.** Publishing is a - multi-second round-trip (Entra token → user delegation key → upload → clipboard), so - `CanvasState.ShareState` records the scoped worktree/doc and the `Publishing` or - `WritingClipboard` phase. Every Share button is disabled while that state is non-idle, only the - matching scoped doc shows the spinner, and the reducer rejects another launch. Results transition - or clear only the matching operation, so navigation and stale async completions cannot unlock or - overwrite a newer share (locked by `ShareCanvasDocResultTests`). -- The action operates on a **single, self-contained doc**. Docs that link to sibling `.html` tabs - are shared as just the focused file; sibling links are inert in the export. Multi-doc bundles are - out of scope. - -### Static export (what gets published) - -The on-disk `.agents/canvas/.html` already contains **none** of the serve-time injected -scripts (bridge heartbeat, `canvasSend`, idiomorph/morph, error overlay) — those are added only by -`CanvasDocServer` at `:5002`. So the export does the **opposite of stripping**: it re-injects the -two pieces a standalone copy needs, and nothing else. - -- Inject the **base theme `" + ) + |> Encoding.UTF8.GetBytes + let private writeShell (context: HttpContext) (_document: BlobDocument) : Task = task { + let content = shellBytes context context.Response.ContentType <- "text/html; charset=utf-8" - context.Response.ContentLength <- shellBytes.LongLength + context.Response.ContentLength <- content.LongLength do! context.Response.Body.WriteAsync( - shellBytes, + content, context.RequestAborted ) } @@ -57,10 +96,13 @@ module internal ViewerApplication = let private handle (reader: BlobReader) (clock: unit -> DateTimeOffset) + contentSecurityPolicy (render: HttpContext -> BlobDocument -> Task) (context: HttpContext) : Task = task { + applyResponsePolicy contentSecurityPolicy context + let prefix = routeSegment "prefix" context let filename = routeSegment "filename" context @@ -78,23 +120,6 @@ module internal ViewerApplication = | NotFound -> context.Response.StatusCode <- StatusCodes.Status404NotFound - } - - let private normalizeNotFound - (context: HttpContext) - (next: RequestDelegate) - : Task = - task { - do! next.Invoke(context) - - if - context.Response.StatusCode - = StatusCodes.Status404NotFound - && not context.Response.HasStarted - then - context.Response.Clear() - context.Response.StatusCode <- - StatusCodes.Status404NotFound context.Response.ContentLength <- 0L } @@ -103,22 +128,35 @@ module internal ViewerApplication = (reader: BlobReader) clock = + builder.WebHost.ConfigureKestrel(fun options -> + options.AddServerHeader <- false) + |> ignore builder.Services.AddRouting() |> ignore let app = builder.Build() - app.Use(fun context next -> normalizeNotFound context next) - |> ignore app.UseRouting() |> ignore app.MapGet( ContentRoute, - RequestDelegate(handle reader clock writeContent) + RequestDelegate( + handle + reader + clock + ContentContentSecurityPolicy + writeContent + ) ) |> ignore app.MapGet( ShellRoute, - RequestDelegate(handle reader clock writeShell) + RequestDelegate( + handle + reader + clock + ShellContentSecurityPolicy + writeShell + ) ) |> ignore diff --git a/src/Tests/CanvasShareViewerTests.fs b/src/Tests/CanvasShareViewerTests.fs index 6cdbac6c..18c13eff 100644 --- a/src/Tests/CanvasShareViewerTests.fs +++ b/src/Tests/CanvasShareViewerTests.fs @@ -3,12 +3,14 @@ module Tests.CanvasShareViewerTests open System open System.Collections.Generic open System.Globalization +open System.IO open System.Net open System.Net.Http open System.Net.Http.Headers open System.Text open System.Threading open System.Threading.Tasks +open System.Xml.Linq open CanvasShareViewer open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Hosting @@ -19,6 +21,12 @@ open Tests.TestUtils let private validPrefix = "0123456789ABCDEFGHIJKL" +let private shellContentSecurityPolicy = + "default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; form-action 'none'; base-uri 'none'" + +let private contentContentSecurityPolicy = + "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; sandbox allow-scripts" + let private formatExpiry (value: DateTimeOffset) = value.ToString("o", CultureInfo.InvariantCulture) @@ -60,9 +68,7 @@ let private withViewer WebApplication.CreateEmptyBuilder( WebApplicationOptions() ) - builder.WebHost.UseKestrel(fun options -> - options.AddServerHeader <- false options.Listen(IPAddress.Loopback, port)) |> ignore @@ -101,6 +107,52 @@ let private headerPairs (headers: HttpHeaders) = header.Key, (header.Value |> Seq.sort |> List.ofSeq)) +let private responseHeadersWithoutDate + (response: HttpResponseMessage) + = + response.Headers + |> headerPairs + |> Seq.sortBy fst + |> List.ofSeq + +let private expectedPolicyHeaders contentSecurityPolicy = + [ + "Cache-Control", [ "no-store" ] + "Content-Security-Policy", + [ contentSecurityPolicy ] + "Referrer-Policy", [ "no-referrer" ] + "X-Content-Type-Options", [ "nosniff" ] + ] + +let private parseHtmlDom (html: string) = + html.Replace( + "", + "", + StringComparison.OrdinalIgnoreCase + ) + |> XDocument.Parse + +let private requiredAttribute + name + (element: XElement) + = + match element.Attribute(XName.Get(name)) |> Option.ofObj with + | Some attribute -> attribute.Value + | None -> + Assert.Fail( + $"Expected <{element.Name.LocalName}> to have a {name} attribute." + ) + "" + +let private selfContainedFixtureBytes () = + Path.Combine( + __SOURCE_DIRECTORY__, + "fixtures", + "canvas-share-viewer", + "self-contained.html" + ) + |> File.ReadAllBytes + type private ResponseSnapshot = { StatusCode: HttpStatusCode Headers: (string * string list) list @@ -435,8 +487,10 @@ type ViewerRouteTests() = ) [] - member _.``shell and content independently read the exact blob``() = - let blobName = $"{validPrefix}/report.html" + member _.``shell routes only to sandboxed content and both routes re-read the blob``() = + let filename = "report & \"notes\".html" + let encodedFilename = Uri.EscapeDataString(filename) + let blobName = $"{validPrefix}/{filename}" let secretMarker = "document-body-secret-marker" let documents = @@ -450,16 +504,30 @@ type ViewerRouteTests() = withViewer documents now (fun fake client baseUrl -> use shell = client.GetAsync( - $"{baseUrl}/c/{validPrefix}/report.html" + $"{baseUrl}/c/{validPrefix}/{encodedFilename}" ) |> await let shellBody = shell.Content.ReadAsStringAsync() |> await + let shellDom = parseHtmlDom shellBody + + let iframe = + shellDom.Descendants(XName.Get("iframe")) + |> Seq.exactlyOne + + let sandboxTokens = + requiredAttribute "sandbox" iframe + |> _.Split( + ' ', + StringSplitOptions.RemoveEmptyEntries + ) + |> Set.ofArray + use content = client.GetAsync( - $"{baseUrl}/c/{validPrefix}/report.html/content" + $"{baseUrl}/c/{validPrefix}/{encodedFilename}/content" ) |> await @@ -478,6 +546,33 @@ type ViewerRouteTests() = "the shell must not carry document content" ) + Assert.That( + shellDom.Descendants(XName.Get("script")), + Is.Empty, + "the shell must expose no script API" + ) + + Assert.That( + requiredAttribute "src" iframe, + Is.EqualTo( + $"/c/{validPrefix}/{encodedFilename}/content" + ) + ) + + Assert.That( + sandboxTokens, + Is.EqualTo(Set.singleton "allow-scripts"), + "the iframe must omit same-origin, forms, popups, downloads, and top-navigation" + ) + + Assert.That( + iframe.Attribute(XName.Get("srcdoc")) + |> Option.ofObj + |> Option.isNone, + Is.True, + "the document must be loaded only from the content route" + ) + Assert.That( content.StatusCode, Is.EqualTo(HttpStatusCode.OK) @@ -494,6 +589,138 @@ type ViewerRouteTests() = "each route must perform its own exact read" ))) + [] + member _.``shell and content emit their exact response policies``() = + let blobName = $"{validPrefix}/report.html" + + let documents = + Map [ + blobName, + document + "content" + liveMetadata + ] + + withViewer documents now (fun _ client baseUrl -> + use shell = + client.GetAsync( + $"{baseUrl}/c/{validPrefix}/report.html" + ) + |> await + + use content = + client.GetAsync( + $"{baseUrl}/c/{validPrefix}/report.html/content" + ) + |> await + + Assert.Multiple(fun () -> + Assert.That( + responseHeadersWithoutDate shell, + Is.EqualTo( + expectedPolicyHeaders + shellContentSecurityPolicy + ) + ) + + Assert.That( + responseHeadersWithoutDate content, + Is.EqualTo( + expectedPolicyHeaders + contentContentSecurityPolicy + ) + ) + + Assert.That( + shell.Content.Headers.ContentType + |> Option.ofObj + |> Option.map string, + Is.EqualTo( + Some "text/html; charset=utf-8" + ) + ) + + Assert.That( + content.Content.Headers.ContentType + |> Option.ofObj + |> Option.map string, + Is.EqualTo( + Some "text/html; charset=utf-8" + ) + ))) + + [] + member _.``content preserves a self-contained active document``() = + let blobName = $"{validPrefix}/self-contained.html" + let fixture = selfContainedFixtureBytes () + + let documents = + Map [ + blobName, + { Content = ReadOnlyMemory(fixture) + Metadata = liveMetadata } + ] + + withViewer documents now (fun _ client baseUrl -> + use response = + client.GetAsync( + $"{baseUrl}/c/{validPrefix}/self-contained.html/content" + ) + |> await + + let actual = + response.Content.ReadAsByteArrayAsync() + |> await + + let dom = + actual + |> Encoding.UTF8.GetString + |> parseHtmlDom + + let style = + dom.Descendants(XName.Get("style")) + |> Seq.exactlyOne + + let script = + dom.Descendants(XName.Get("script")) + |> Seq.exactlyOne + + let image = + dom.Descendants(XName.Get("img")) + |> Seq.exactlyOne + + Assert.Multiple(fun () -> + Assert.That( + response.StatusCode, + Is.EqualTo(HttpStatusCode.OK) + ) + + Assert.That( + actual, + Is.EqualTo(fixture), + "the exported HTML must be streamed unchanged" + ) + + Assert.That( + style.Value, + Does.Contain("#execution-status") + ) + + Assert.That( + script.Value, + Does.Contain("window.eval(") + ) + + Assert.That( + script.Value, + Does.Contain("new Function(") + ) + + Assert.That( + requiredAttribute "src" image, + Does.StartWith("data:image/") + ))) + [] member _.``all not-found outcomes are indistinguishable on both routes``() = let expiredName = $"{validPrefix}/expired.html" @@ -541,36 +768,49 @@ type ViewerRouteTests() = ] withViewer documents now (fun fake client baseUrl -> - let requests = - cases - |> List.collect (fun (path, _) -> - [ - $"{baseUrl}/c/{path}" - $"{baseUrl}/c/{path}/content" - ]) - let snapshots = - requests - |> List.map (responseSnapshot client) - - let expected = + cases + |> List.map (fun (path, _) -> + responseSnapshot + client + $"{baseUrl}/c/{path}", + responseSnapshot + client + $"{baseUrl}/c/{path}/content") + + let expectedShell, expectedContent = snapshots |> List.head Assert.Multiple(fun () -> snapshots - |> List.iter (fun actual -> + |> List.iter (fun (shell, content) -> Assert.That( - actual, - Is.EqualTo(expected) + shell, + Is.EqualTo(expectedShell) ) Assert.That( - actual.StatusCode, + content, + Is.EqualTo(expectedContent) + ) + + Assert.That( + shell.StatusCode, Is.EqualTo(HttpStatusCode.NotFound) ) Assert.That( - actual.Body, + content.StatusCode, + Is.EqualTo(HttpStatusCode.NotFound) + ) + + Assert.That( + shell.Body, + Is.Empty + ) + + Assert.That( + content.Body, Is.Empty )) diff --git a/src/Tests/fixtures/canvas-share-viewer/self-contained.html b/src/Tests/fixtures/canvas-share-viewer/self-contained.html new file mode 100644 index 00000000..a79f929e --- /dev/null +++ b/src/Tests/fixtures/canvas-share-viewer/self-contained.html @@ -0,0 +1,19 @@ + + + + + Self-contained viewer fixture + + + +
ready
+ embedded pixel + + + From 970d962e4986b64671c76d98084fbe7b040f1896 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Mon, 17 Aug 2026 18:03:20 +0200 Subject: [PATCH 04/27] tm-canvas-safe-share-ydm Switch publisher to clean viewer URLs Replace user-delegation SAS minting with private-Blob uploads that carry expiresOn metadata, add a validated HTTPS viewerBaseUrl setting, raise the expiry ceiling to 30 days, and return clean /c// viewer URLs. --- docs/spec/canvas-pane.md | 4 +- docs/spec/canvas-sharing.md | 3 + docs/spec/remoting-csrf-hardening.md | 9 +- src/Client/AppTypes.fs | 2 +- src/Client/CanvasUpdate.fs | 8 +- src/Client/index.html | 2 +- src/Server/CanvasShare.fs | 140 +++++--------- src/Server/GlobalConfig.fs | 58 +++--- src/Server/WorktreeApi.fs | 8 +- src/Shared/Types.fs | 6 +- src/Tests/CanvasAwarenessTests.fs | 5 +- src/Tests/CanvasShareClientTests.fs | 16 +- src/Tests/CanvasShareTests.fs | 271 +++++++++++++++++---------- 13 files changed, 297 insertions(+), 235 deletions(-) diff --git a/docs/spec/canvas-pane.md b/docs/spec/canvas-pane.md index f1a01027..5f06546b 100644 --- a/docs/spec/canvas-pane.md +++ b/docs/spec/canvas-pane.md @@ -69,7 +69,7 @@ A `SystemView` drives its own updates, so it needs neither morph nor the author - Selecting a tab marks that doc viewed. - Viewed but inactive tabs render at 0.5 opacity. The active tab stays full opacity. - The archive button moves the active doc to `.agents/canvas/archive/`. It is shown only when the active doc is an `AgentDoc` — a `SystemView` is server-regenerated, not user-owned, so it has no archive button. -- The share button publishes the active doc to an unguessable, auto-expiring URL and copies a rich titled link to the clipboard. Like archive, it is shown only when the active doc is an `AgentDoc` — a `SystemView` is server-generated, not shareable, so it has no share button. Its banner is a separate, dismissible `ShareNotice` channel (green), independent of the send `Waiting` and delivery-`Failed` banners: a successful publish shows `Shared — link copied` (or `Shared — link ready, copy it manually: ` when the async clipboard write is rejected), while a *failed* publish reuses the existing red `CanvasSendState.Failed` error banner. Success and failure are mutually exclusive — each result arm clears the other channel — so a red + green stack never renders. See `docs/spec/canvas-sharing.md` for the full publish/SAS/clipboard flow. +- The share button publishes the active doc to an unguessable, auto-expiring authenticated-viewer URL and copies a rich titled link to the clipboard. Like archive, it is shown only when the active doc is an `AgentDoc` — a `SystemView` is server-generated, not shareable, so it has no share button. Its banner is a separate, dismissible `ShareNotice` channel (green), independent of the send `Waiting` and delivery-`Failed` banners: a successful publish shows `Shared — link copied` (or `Shared — link ready, copy it manually: ` when the async clipboard write is rejected), while a *failed* publish reuses the existing red `CanvasSendState.Failed` error banner. Success and failure are mutually exclusive — each result arm clears the other channel — so a red + green stack never renders. See `docs/spec/canvas-sharing.md` for the full publish/viewer/clipboard flow. ### Canvas Overview @@ -293,7 +293,7 @@ changed rows already use). - `docs/spec/worktree-monitor.md` — parent dashboard architecture spec - `docs/spec/beadspace-canvas.md` — beads dashboard integration in the canvas pane -- `docs/spec/canvas-sharing.md` — one-click Share of a focused `AgentDoc` to an unguessable, auto-expiring URL (the tab-bar Share button, its publish/SAS backend, and the clipboard rich link) +- `docs/spec/canvas-sharing.md` — one-click Share of a focused `AgentDoc` to an unguessable, auto-expiring authenticated-viewer URL (the tab-bar Share button, private-Blob publisher, and clipboard rich link) - `docs/spec/canvas-interaction-routing.md` — ownership, generated-view affinity, queueing, and session routing - `docs/spec/worktree-diff-viewer.md` — generated worktree diff SystemView - `docs/spec/future/canvas-roadmap.md` — remaining canvas work (authoring DX, templates) diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index 19a482e7..626954cd 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -192,6 +192,9 @@ remote-URL fetch that would otherwise be a working exfiltration channel. - The publisher keeps its existing delegated Entra/Azure CLI identity and `Storage Blob Data Contributor` grant. The viewer uses a managed identity with the new read-only grant scoped to the share container. +- Provisioning ensures the configured share container exists with anonymous access disabled before + either container-scoped grant is applied; the publisher intentionally does not create containers + at share time. - The canonical App Service, identity, Entra configuration, RBAC grants, and lifecycle policy remain deployed after verification. Verification removes only its document fixtures and any auxiliary resources created solely to prove the permission boundary. diff --git a/docs/spec/remoting-csrf-hardening.md b/docs/spec/remoting-csrf-hardening.md index 5fa6811a..113d2c84 100644 --- a/docs/spec/remoting-csrf-hardening.md +++ b/docs/spec/remoting-csrf-hardening.md @@ -58,10 +58,11 @@ are the high-value targets; fixing it once at the pipeline protects all of them. materially higher impact. - A later endpoint — `shareCanvasDoc` (canvas doc sharing; see `docs/spec/canvas-sharing.md` §"Security Posture", finding F16) — adds a **data-egress** class to this surface: a forged call - publishes a local canvas file to an internet-reachable blob SAS URL. It stays Low (the response is - CORS-unreadable and the target worktree path is machine-specific / non-enumerable), but it is the - first member whose forged invocation *exfiltrates* rather than only mutating or launching — a further - reason to land the central fix. + publishes a local canvas file to private Blob storage behind an internet-reachable authenticated + viewer URL. It stays Low (the response is CORS-unreadable, the target worktree path is + machine-specific / non-enumerable, and the viewer still requires Entra authorization), but it is + the first member whose forged invocation *exfiltrates* rather than only mutating or launching — a + further reason to land the central fix. ## Goals diff --git a/src/Client/AppTypes.fs b/src/Client/AppTypes.fs index d91a5618..2c269e38 100644 --- a/src/Client/AppTypes.fs +++ b/src/Client/AppTypes.fs @@ -112,7 +112,7 @@ type Msg = | OpenWorktreeDiff of scopedKey: string | ArchiveCanvasDoc of scopedKey: string * filename: string | ArchiveCanvasDocResult of scopedKey: string * filename: string * Result - // Share the focused AgentDoc: publish it (server mints a per-doc read-only SAS URL + returns the + // Share the focused AgentDoc: publish it (server returns a clean authenticated-viewer URL + the // doc title) then write a rich clipboard link. ShareCanvasDocResult carries the CanvasShareResult // on Ok (→ dual-format clipboard write, deferring the banner to ClipboardWriteResult) or an error // message on failure (→ the existing error banner). ClipboardWriteResult reports whether the async diff --git a/src/Client/CanvasUpdate.fs b/src/Client/CanvasUpdate.fs index e56df7e4..a0e1ba4d 100644 --- a/src/Client/CanvasUpdate.fs +++ b/src/Client/CanvasUpdate.fs @@ -271,9 +271,9 @@ let archiveCanvasDocResult (scopedKey: string) (filename: string) (result: Resul /// The two clipboard formats written on a successful share (see `buildClipboardPayload`). type ClipboardPayload = { /// `text/html` — a titled `` so rich targets (Teams, Slack, Outlook, Gmail, Word) render a - /// hyperlink whose visible text is the doc title, hiding the long SAS URL. + /// hyperlink whose visible text is the doc title. Html: string - /// `text/plain` — the raw SAS URL, for plain targets (the VS Code editor, a terminal, Notepad). + /// `text/plain` — the raw viewer URL, for plain targets (the VS Code editor, a terminal, Notepad). Text: string } /// Escape the four characters that would otherwise break the rich `` — used for @@ -299,7 +299,7 @@ let buildClipboardPayload (result: CanvasShareResult) : ClipboardPayload = /// user activation / an active document, both of which can be lost across the share network round-trip; /// the permission may be revoked; or the API/`ClipboardItem` may be unavailable, which throws /// synchronously). Every one of those paths dispatches an `Error` so the success banner can correct its -/// "link copied" claim instead of lying (F6). `payload.Text` is the raw SAS URL, threaded into the +/// "link copied" claim instead of lying (F6). `payload.Text` is the raw viewer URL, threaded into the /// result so a failed copy can still surface a manually-copyable link. let private writeClipboardCmd (scopedKey: string) (filename: string) (payload: ClipboardPayload) : Cmd = Cmd.ofEffect (fun dispatch -> @@ -364,7 +364,7 @@ let shareCanvasDocResult (scopedKey: string) (filename: string) (result: Result< /// Banner text for a *settled* clipboard write after a successful share (Decision #10). A landed write /// confirms the copy ("Shared — link copied"); a rejected write drops the false "copied" claim, tells -/// the user the link is ready, and surfaces the raw SAS URL as selectable text so they can still copy +/// the user the link is ready, and surfaces the raw viewer URL as selectable text so they can still copy /// it by hand. Pure so the copied-vs-manual text is unit-testable without a browser clipboard. let clipboardResultNotice (url: string) (outcome: Result) : string = match outcome with diff --git a/src/Client/index.html b/src/Client/index.html index 48dce65e..e442529f 100644 --- a/src/Client/index.html +++ b/src/Client/index.html @@ -833,7 +833,7 @@ } .canvas-share-btn .btn-icon { width: 14px; height: 14px; } .canvas-share-btn:hover { color: #a6e3a1; border-color: #a6e3a1; opacity: 1; } - /* Publishing is a multi-second round-trip (Entra token + user delegation key + upload). Hold the + /* Publishing is a multi-second round-trip (delegated Azure authentication + upload). Hold the button lit and spinning for the whole wait — the same reason .auto-sync-btn.syncing exists: without it an in-flight share is indistinguishable from a button that did nothing. */ .canvas-share-btn.sharing { opacity: 1; color: #a6e3a1; border-color: #a6e3a1; cursor: default; } diff --git a/src/Server/CanvasShare.fs b/src/Server/CanvasShare.fs index cc4526d6..750f1734 100644 --- a/src/Server/CanvasShare.fs +++ b/src/Server/CanvasShare.fs @@ -1,34 +1,27 @@ -/// Publishes an already-exported, standalone canvas doc to Azure Blob Storage and mints a per-doc, -/// read-only SAS URL a recipient can open in a plain browser (no login). Deliberately independent of -/// BOTH the Shared API contract and CanvasExport: `publish` takes an already-exported HTML string -/// plus the doc's filename, so the caller (`WorktreeApi.shareCanvasDocImpl`) owns the export step and -/// the assembly of the `CanvasShareResult`. That keeps this module a thin, replaceable storage -/// adapter with only three dependencies: `Azure.Storage.Blobs`, `Azure.Identity` and `GlobalConfig`. +/// Publishes an already-exported, standalone canvas doc to a pre-provisioned private Azure Blob +/// container and returns its clean authenticated-viewer URL. Deliberately independent of BOTH the +/// Shared API contract and CanvasExport: `publish` takes an already-exported HTML string plus the +/// doc's filename, so `WorktreeApi.shareCanvasDocImpl` owns export and `CanvasShareResult` assembly. /// -/// Credential model (docs/spec/canvas-sharing.md, Decision #3): Treemon stores no storage credential. -/// Links are signed with a *user delegation key* fetched through `AzureCliCredential`, which uses the -/// operator's persisted Azure CLI login. The account rejects Shared Key authorization, so Treemon -/// never handles an account key or connection string. The cost is Azure's hard **7-day** ceiling on a -/// user delegation key — and therefore on every link. +/// Treemon stores no storage credential. A cached `AzureCliCredential` pipeline uses the operator's +/// delegated Azure identity for uploads, while recipients authenticate to the separate viewer and +/// never receive a Blob credential. Each blob carries its own expiry metadata and lands at an +/// unguessable `/` name. The prefix narrows exact lookup but is not an +/// authorization grant: the viewer's Entra gate and synchronous expiry check enforce access. /// -/// Secrecy model (Decisions #2/#4/#5): the container is PRIVATE (anonymous access disabled at the -/// account), the blob lands under an unguessable `/` name, and the returned -/// link is a blob-scoped, read-only, https-only SAS (`sr=b`, `sp=r`, `spr=https`). Because the SAS is -/// blob-scoped, a leaked link exposes exactly one doc; per-doc revoke is a blob delete. -/// -/// The SAS is additionally bound to the *signing identity*: it carries `skoid`/`sktid`. Azure caches -/// role assignments and user delegation keys, so role removal or key revocation invalidates existing -/// links only after cache propagation, not immediately. +/// Shared active HTML is never served directly from Blob Storage. The viewer renders it in the +/// sandbox/CSP boundary described in `docs/spec/canvas-sharing.md`; deleting the backing blob still +/// revokes one document immediately. module Server.CanvasShare open System open System.Collections.Concurrent +open System.Globalization open System.IO open System.Text open Azure.Identity open Azure.Storage.Blobs open Azure.Storage.Blobs.Models -open Azure.Storage.Sas open FsToolkit.ErrorHandling open Server.GlobalConfig @@ -36,11 +29,15 @@ open Server.GlobalConfig /// `/` so it can't muddle the `/` split. let private base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" -/// Length of the random prefix. 22 base62 chars ≈ 131 bits of entropy — far beyond guessable; the -/// SAS signature (not the name) is the real gate anyway (Decision #5). +/// Length of the opaque random prefix. 22 base62 chars ≈ 131 bits of entropy; it is a durable, +/// unguessable lookup identifier while Entra authentication remains the authorization gate. [] let internal PrefixLength = 22 +/// Publisher/viewer wire-contract key for the view-time-enforced expiry. +[] +let internal ExpiryMetadataKey = "expiresOn" + /// A fresh high-entropy base62 prefix from the cryptographic RNG. `GetString` samples the alphabet /// uniformly (no modulo bias). Impure (RNG) but shape-testable: right length, alphabet-only, and /// distinct across calls. @@ -54,33 +51,27 @@ let internal leafName (filename: string) : string = filename.Replace('\\', '/').Split('/') |> Array.last /// The blob name a published doc lands at: `/`. The random prefix gives -/// uniqueness + unguessability; the real filename gives the recipient a meaningful page/tab title -/// (Decision #5). Pure given the prefix, so the naming shape is unit-testable. +/// uniqueness + unguessability; the real filename gives the recipient a meaningful page/tab title. +/// Pure given the prefix, so the naming shape is unit-testable. let internal blobName (prefix: string) (filename: string) : string = $"{prefix}/{leafName filename}" -/// Builds the per-doc SAS grant: blob-scoped (`sr=b`, `Resource = "b"`), read-only (`sp=r`), -/// https-only (`spr=https`), expiring at `expiresOn`. Pure — it holds no credential and touches no -/// network (the user delegation key is applied later by `ToSasQueryParameters`), so the exact -/// least-privilege grant is unit-testable in isolation. Least privilege (Decision #2): a recipient of -/// doc A's link cannot read doc B because the signature is bound to A's blob. -/// -/// `expiresOn` must fall inside the delegation key's own validity window or the link is refused at -/// use time regardless of what the token says, so `publish` derives both from one start instant. -let internal buildSasBuilder (containerName: string) (blob: string) (expiresOn: DateTimeOffset) : BlobSasBuilder = - BlobSasBuilder( - BlobSasPermissions.Read, expiresOn, - BlobContainerName = containerName, - BlobName = blob, - Resource = "b", - Protocol = SasProtocol.Https) +/// Builds the upload contract shared with the viewer: UTF-8 HTML plus an exact `expiresOn` +/// metadata value in UTC round-trip form. +let internal buildUploadOptions (expiresOn: DateTimeOffset) = + let headers = BlobHttpHeaders(ContentType = "text/html; charset=utf-8") + let metadata = + dict [ + ExpiryMetadataKey, + expiresOn.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture) + ] + BlobUploadOptions(HttpHeaders = headers, Metadata = metadata) -let internal buildSignedBlobUrl - (blobUri: Uri) - (accountName: string) - (delegationKey: UserDelegationKey) - (sasBuilder: BlobSasBuilder) = - $"{blobUri}?{sasBuilder.ToSasQueryParameters(delegationKey, accountName)}" +/// Constructs a recipient URL without consulting the Blob URI. The filename is encoded as one path +/// segment, and validated config guarantees the base has no query or fragment to carry through. +let internal buildViewerUrl (viewerBaseUrl: Uri) (prefix: string) (filename: string) = + let encodedFilename = filename |> leafName |> Uri.EscapeDataString + $"{viewerBaseUrl.AbsoluteUri.TrimEnd('/')}/c/{prefix}/{encodedFilename}" let private credential = lazy (AzureCliCredential()) @@ -98,10 +89,10 @@ let internal serviceClient (accountName: string) = credential.Value))) client.Value -/// The client-facing "not configured" message names the non-secret account config rather than +/// The client-facing "not configured" message names both required non-secret endpoints rather than /// suggesting an application-managed key or connection string. let internal notConfiguredMessage = - "Canvas sharing is not configured — set canvasShare.accountName in ~/.treemon/config.json to an Azure Storage account name." + "Canvas sharing is not configured — set canvasShare.accountName and an HTTPS canvasShare.viewerBaseUrl in ~/.treemon/config.json." /// The message shown when the host has no usable Entra identity (e.g. `az login` has expired). This /// is the one routine operational failure of the credential model, so it names the fix rather than @@ -109,61 +100,32 @@ let internal notConfiguredMessage = let internal signInRequiredMessage = "Canvas sharing could not authenticate to Azure — run `az login` on this host and try again." -/// Publish an already-exported standalone HTML doc; return a per-doc read-only SAS URL string. -/// -/// Uploads `html` to the PRIVATE container (created on first use if absent) at -/// `/` with `Content-Type: text/html`, then signs a blob-scoped read-only -/// https SAS with an Entra **user delegation key** and returns its absolute URL. Returns `Error` -/// (never throws) when the backend is unconfigured, when the host has no Entra identity, or on any -/// storage failure. No returned message or log line contains the full SAS. +/// Publish an already-exported standalone HTML doc and return its clean viewer URL. /// -/// SECURITY (accepted risk — focused-review F17): `html` is author-controlled canvas content, and it -/// is published as ACTIVE, non-sandboxed HTML/JS with only `Content-Type` — no CSP, no -/// `X-Content-Type-Options`, no sanitization — so whatever JS it contains runs top-level in the -/// recipient's browser for the life of the link. Not sanitized on purpose: that would break the -/// feature's interactivity goal (Decision #6), and a per-blob CSP would need a CDN/proxy. Documented, -/// accepted trade-off — see `docs/spec/canvas-sharing.md` §"Security Posture". +/// Uploads `html` to the configured pre-provisioned private container at +/// `/`, with UTF-8 HTML headers and the exact expiry metadata the viewer +/// enforces. Returns `Error` (never throws) when either endpoint is unconfigured, the host has no +/// usable delegated identity, or storage fails. Returned errors and logs contain no recipient URL. let publish (filename: string) (html: string) : Async> = asyncResult { let config = readCanvasShareConfig () let! accountName = config.AccountName |> Result.requireSome notConfiguredMessage + let! viewerBaseUrl = config.ViewerBaseUrl |> Result.requireSome notConfiguredMessage // The try/with stays around the Azure SDK calls (a genuine interop boundary); the - // Option→Error gate above is flattened into the asyncResult track. + // configuration gates above are flattened into the asyncResult track and run before any + // credential acquisition or network call. try let serviceClient = serviceClient accountName - // Backdate the start to absorb clock skew between this host and the storage service, and - // derive the expiry from it so the window stays strictly inside Azure's 7-day limit on a - // user delegation key — at the maximum configured expiry, `expiresOn` is a few minutes - // short of `now + 7d` rather than exactly on the boundary, which the service rejects. - let startsOn = DateTimeOffset.UtcNow.AddMinutes(-5.0) - let expiresOn = startsOn.AddDays(float config.DefaultExpiryDays) - let! delegationKey = - // The explicit CancellationToken selects the (startsOn, expiresOn, ct) overload; with - // two arguments F# binds the same-arity (options, ct) overload instead and fails. - serviceClient.GetUserDelegationKeyAsync( - Nullable startsOn, expiresOn, Threading.CancellationToken.None) - |> Async.AwaitTask + let expiresOn = DateTimeOffset.UtcNow.AddDays(float config.DefaultExpiryDays) let containerClient = serviceClient.GetBlobContainerClient(config.Container) - // Create the PRIVATE container on demand (idempotent) so a fresh account/subscription - // works on first publish — the SDK never auto-creates it, and a missing container - // otherwise fails the upload with 404 ContainerNotFound. PublicAccessType.None keeps - // anonymous access off at the container level (Decision #4). - let! _ = containerClient.CreateIfNotExistsAsync(PublicAccessType.None) |> Async.AwaitTask - let blob = blobName (generatePrefix ()) filename + let prefix = generatePrefix () + let blob = blobName prefix filename let blobClient = containerClient.GetBlobClient(blob) - // charset is declared so non-ASCII doc content isn't mojibaked when the blob is - // opened standalone (the export injects no ). - let headers = BlobHttpHeaders(ContentType = "text/html; charset=utf-8") use stream = new MemoryStream(Encoding.UTF8.GetBytes html) let! _ = - blobClient.UploadAsync(stream, BlobUploadOptions(HttpHeaders = headers)) + blobClient.UploadAsync(stream, buildUploadOptions expiresOn) |> Async.AwaitTask - return - buildSignedBlobUrl - blobClient.Uri - accountName - delegationKey.Value - (buildSasBuilder config.Container blob expiresOn) + return buildViewerUrl viewerBaseUrl prefix filename with | :? AuthenticationFailedException as ex -> // The identity itself is unusable (expired/absent az login). Log the type only — an diff --git a/src/Server/GlobalConfig.fs b/src/Server/GlobalConfig.fs index 6dcae927..deb0e3c3 100644 --- a/src/Server/GlobalConfig.fs +++ b/src/Server/GlobalConfig.fs @@ -281,31 +281,34 @@ let internal writeCanvasSize (size: CanvasSize) = updateGlobalConfig "canvas size" [ "canvasSize", System.Text.Json.Nodes.JsonValue.Create(value) :> System.Text.Json.Nodes.JsonNode ] /// Machine-level config for the canvas Share backend (the `canvasShare` section of `config.json`): -/// which storage account published docs go to, which PRIVATE container they land in, and the default -/// per-doc SAS expiry. Treemon stores no storage credential: links are signed with an Entra *user -/// delegation key* obtained through `AzureCliCredential`, so the account name is ordinary non-secret -/// config (spec docs/spec/canvas-sharing.md, Configuration). +/// which storage account and PRIVATE container receive published docs, their default lifetime, and +/// the HTTPS viewer base URL used for recipient links. These are ordinary non-secret settings; the +/// publisher authenticates to storage with `AzureCliCredential`. type CanvasShareConfig = { AccountName: string option Container: string - DefaultExpiryDays: int } - -/// Defaults for a missing `canvasShare` section or field. `AccountName` has no default — without it -/// there is nothing to publish to, so it is the single value an operator must supply and its absence -/// is what "not configured" means. -let defaultCanvasShareConfig = { AccountName = None; Container = "canvas-shared"; DefaultExpiryDays = 7 } - -/// Upper bound on a configured `defaultExpiryDays`, set by Azure rather than by us: a user delegation -/// key is valid for at most **7 days**, and a SAS signed with it dies when the key does regardless of -/// the expiry written into the token. Requesting more is rejected outright when the key is minted, so -/// a larger — or non-positive — value is treated as absent and falls back to the default. -let internal maxCanvasShareExpiryDays = 7 + DefaultExpiryDays: int + ViewerBaseUrl: Uri option } + +/// Storage has a safe container/lifetime default, but neither deployment-specific endpoint does: +/// both the account and viewer URL must be configured by the operator. The deployed viewer uses +/// `https://treemon.azurewebsites.net`; keeping it out of the default preserves test/deployment +/// isolation and makes an incomplete setup fail closed. +let defaultCanvasShareConfig = + { AccountName = None + Container = "canvas-shared" + DefaultExpiryDays = 7 + ViewerBaseUrl = None } + +/// Durable product limit for a shared document's view-time-enforced lifetime. Lifecycle cleanup is +/// configured beyond this boundary; values outside the range fall back to the seven-day default. +let internal maxCanvasShareExpiryDays = 30 /// Reads the `canvasShare` config section, falling back to `defaultCanvasShareConfig` for a missing -/// section or field. A blank `accountName` or `container`, or a `defaultExpiryDays` outside -/// `1 .. maxCanvasShareExpiryDays`, is treated as absent (a non-positive expiry would mint an -/// already-dead link; one beyond the 7-day user-delegation-key limit would be refused by Azure at -/// publish), so a partial or typo'd section still yields a working config rather than a broken one. +/// section or field. Blank account/container values, a non-HTTPS/invalid `viewerBaseUrl`, or a +/// `defaultExpiryDays` outside `1 .. maxCanvasShareExpiryDays` are treated as absent. User info, +/// query strings, and fragments are not valid on the base URL because published links must be clean +/// paths without embedded credentials. let internal readCanvasShareConfig () : CanvasShareConfig = withConfigDocument defaultCanvasShareConfig (fun root -> match root.TryGetProperty("canvasShare") with @@ -315,6 +318,18 @@ let internal readCanvasShareConfig () : CanvasShareConfig = | true, v when v.ValueKind = System.Text.Json.JsonValueKind.String && v.GetString().Trim() <> "" -> Some(v.GetString().Trim()) | _ -> None + let httpsBaseUrl name = + trimmedString name + |> Option.bind (fun value -> + match Uri.TryCreate(value, UriKind.Absolute) with + | true, uri + when uri.Scheme = Uri.UriSchemeHttps + && not (String.IsNullOrWhiteSpace(uri.Host)) + && String.IsNullOrEmpty(uri.UserInfo) + && String.IsNullOrEmpty(uri.Query) + && String.IsNullOrEmpty(uri.Fragment) -> + Some uri + | _ -> None) let expiryDays = match section.TryGetProperty("defaultExpiryDays") with | true, e when e.ValueKind = System.Text.Json.JsonValueKind.Number -> @@ -324,7 +339,8 @@ let internal readCanvasShareConfig () : CanvasShareConfig = | _ -> defaultCanvasShareConfig.DefaultExpiryDays { AccountName = trimmedString "accountName" Container = trimmedString "container" |> Option.defaultValue defaultCanvasShareConfig.Container - DefaultExpiryDays = expiryDays } + DefaultExpiryDays = expiryDays + ViewerBaseUrl = httpsBaseUrl "viewerBaseUrl" } | _ -> defaultCanvasShareConfig) let internal readLastViewedHashes () : Map> = diff --git a/src/Server/WorktreeApi.fs b/src/Server/WorktreeApi.fs index 5b01261e..6fdc16f2 100644 --- a/src/Server/WorktreeApi.fs +++ b/src/Server/WorktreeApi.fs @@ -108,8 +108,8 @@ let private archiveCanvasDocImpl (request: ArchiveCanvasDocRequest) = /// Share a canvas doc: validate the path → read the on-disk file → static-export it /// (`CanvasExport.buildStaticHtml` re-injects theme + no-op canvasSend) → publish to Azure Blob and -/// mint a per-doc read-only SAS (`CanvasShare.publish`) → assemble the `CanvasShareResult` with the -/// SAS URL and the doc's resolved title. Mirrors `archiveCanvasDocImpl`. `Title` uses +/// record its expiry (`CanvasShare.publish`) → assemble the `CanvasShareResult` with the clean viewer +/// URL and the doc's resolved title. Mirrors `archiveCanvasDocImpl`. `Title` uses /// `CanvasExport.resolveTitle` (the doc's ``, falling back to a prettified filename) because /// `CanvasShareResult.Title` is a plain string, not an option; the title is read from the original /// HTML (`buildStaticHtml` injects only at `</head>`, so it never alters the doc's `<title>`). @@ -130,9 +130,9 @@ let private shareCanvasDocImpl (request: ShareCanvasDocRequest) : Async<Result<C return! Error $"File not found: {request.Filename}" let html = File.ReadAllText sourcePath - let! sasUrl = Server.CanvasShare.publish request.Filename (Server.CanvasExport.buildStaticHtml html) + let! viewerUrl = Server.CanvasShare.publish request.Filename (Server.CanvasExport.buildStaticHtml html) return - { Url = sasUrl + { Url = viewerUrl Title = Server.CanvasExport.resolveTitle html request.Filename } } diff --git a/src/Shared/Types.fs b/src/Shared/Types.fs index 12d0347c..839fc218 100644 --- a/src/Shared/Types.fs +++ b/src/Shared/Types.fs @@ -493,9 +493,9 @@ type ShareCanvasDocRequest = { WorktreePath: WorktreePath Filename: string } -/// Result of publishing a canvas doc: the per-doc read-only SAS URL plus the doc's title -/// (extracted server-side from the HTML) so the client can build the rich clipboard link -/// without re-parsing. +/// Result of publishing a canvas doc: the clean authenticated-viewer URL plus the doc's title +/// (extracted server-side from the HTML) so the client can build the rich clipboard link without +/// re-parsing. type CanvasShareResult = { Url: string Title: string } diff --git a/src/Tests/CanvasAwarenessTests.fs b/src/Tests/CanvasAwarenessTests.fs index dec9b8a9..e7583e8a 100644 --- a/src/Tests/CanvasAwarenessTests.fs +++ b/src/Tests/CanvasAwarenessTests.fs @@ -1079,7 +1079,8 @@ type DocErrorTests() = type ShareCanvasDocResultTests() = let shareResult : CanvasShareResult = - { Url = "https://acct.blob.core.windows.net/canvas/x/status.html?sig=s"; Title = "Status" } + { Url = "https://viewer.test/c/0123456789AbCdEfGhIjKl/status.html" + Title = "Status" } let publishingModel sendState = { defaultModel with @@ -1172,7 +1173,7 @@ type ShareCanvasDocResultTests() = Assert.That(notice, Does.Not.Contain("link copied"), "A rejected clipboard write must NOT claim the link was copied (F6)") Assert.That(notice, Does.Contain(shareResult.Url), - "The raw SAS URL must be surfaced so the user can copy it manually") + "The raw viewer URL must be surfaced so the user can copy it manually") | None -> Assert.Fail("A settled clipboard write must still raise the share banner") [<Test>] diff --git a/src/Tests/CanvasShareClientTests.fs b/src/Tests/CanvasShareClientTests.fs index 633888c3..2f41e188 100644 --- a/src/Tests/CanvasShareClientTests.fs +++ b/src/Tests/CanvasShareClientTests.fs @@ -13,29 +13,27 @@ open CanvasUpdate [<Category("Canvas")>] type BuildClipboardPayloadTests() = - // A representative per-doc SAS URL: long, and carrying `&`-separated query params so the test - // also exercises HTML-escaping of the href. - let sasUrl = "https://acct.blob.core.windows.net/canvas/9fA2/build-status.html?sv=2023-11-03&sr=b&sp=r&sig=aB%2Bc%3D" + let viewerUrl = + "https://viewer.test/c/0123456789AbCdEfGhIjKl/build-status.html" [<Test>] member _.``Writes BOTH formats: titled text/html anchor + plain-text URL``() = - let result = { Url = sasUrl; Title = "Build Status Report" } + let result = { Url = viewerUrl; Title = "Build Status Report" } let payload = buildClipboardPayload result // text/plain is the raw URL, verbatim (plain targets get the link itself). - Assert.That(payload.Text, Is.EqualTo(sasUrl), "text/plain must be the raw SAS URL") + Assert.That(payload.Text, Is.EqualTo(viewerUrl), "text/plain must be the raw viewer URL") // text/html is a titled anchor: the visible text is the doc title, the href is the URL. Assert.That(payload.Html, Does.StartWith("<a href=\""), "text/html must be an anchor") Assert.That(payload.Html, Does.EndWith("</a>")) Assert.That(payload.Html, Does.Contain(">Build Status Report</a>"), "anchor text must be the title") - // The href is HTML-escaped (the URL's `&` becomes `&`) so the anchor can't be truncated. - Assert.That(payload.Html, Does.Contain("href=\"https://acct.blob.core.windows.net/canvas/9fA2/build-status.html?sv=2023-11-03&sr=b&sp=r&sig=aB%2Bc%3D\""), - "href must carry the full, HTML-escaped SAS URL") + Assert.That(payload.Html, Does.Contain($"href=\"{viewerUrl}\""), + "href must carry the full clean viewer URL") [<Test>] member _.``HTML-special characters in the title are escaped in the anchor text``() = - let result = { Url = "https://acct.blob.core.windows.net/canvas/x/a.html?sig=x"; Title = "A & B <tag> \"q\"" } + let result = { Url = viewerUrl; Title = "A & B <tag> \"q\"" } let payload = buildClipboardPayload result Assert.That(payload.Html, Does.Contain(">A & B <tag> "q"</a>"), diff --git a/src/Tests/CanvasShareTests.fs b/src/Tests/CanvasShareTests.fs index ba63a530..d17b940f 100644 --- a/src/Tests/CanvasShareTests.fs +++ b/src/Tests/CanvasShareTests.fs @@ -4,20 +4,13 @@ open System open System.IO open System.Text.RegularExpressions open NUnit.Framework -open Azure.Storage.Blobs.Models -open Azure.Storage.Sas open Server open Server.CanvasShare open Server.GlobalConfig open Tests.TestUtils -// This suite covers only the PURE and deterministic parts of the publish backend (spec -// docs/spec/canvas-sharing.md): blob naming, user-delegation signing, service-client reuse and the -// config reader — plus the unconfigured gate, which fails before any credential or network use. -// The Azure round-trip cannot be emulated (Azurite does not implement GetUserDelegationKey), so it is -// verified against the real account instead — see the spec's "Verification" section. - -// ── blob naming (pure) ──────────────────────────────────────────────────────── +// Pure publisher contracts plus the fail-before-network configuration gate. The real Azure +// round-trip is covered by the deployment verification described in docs/spec/canvas-sharing.md. [<TestFixture>] [<Category("Unit")>] @@ -30,7 +23,6 @@ type BlobNamingTests() = [<Test>] member _.``blobName keeps the real filename so the recipient sees a meaningful title``() = - // Decision #5: the real filename is preserved (not hashed) after the unguessable prefix. Assert.That(blobName "abc" "weekly-sync.html", Does.EndWith("/weekly-sync.html")) [<Test>] @@ -66,79 +58,127 @@ type BlobNamingTests() = "every minted prefix must be distinct") -// ── SAS grant parameters (pure) ─────────────────────────────────────────────── - [<TestFixture>] [<Category("Unit")>] [<Category("Fast")>] -type SasBuilderTests() = +type UploadContractTests() = - let expiresOn = DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero) - let build () = buildSasBuilder "canvas-shared" "prefix/doc.html" expiresOn + let expiresOn = + DateTimeOffset(2030, 1, 2, 3, 4, 5, TimeSpan.FromHours(2.0)) [<Test>] - member _.``buildSasBuilder scopes the grant to a single blob (sr=b)``() = - // Blob-scoped is the crux of least privilege (Decision #2): doc A's link can't read doc B. - Assert.That(build().Resource, Is.EqualTo("b")) + member _.``upload writes only expiresOn metadata in UTC round-trip form``() = + let options = buildUploadOptions expiresOn - [<Test>] - member _.``buildSasBuilder grants read-only permission (sp=r)``() = - Assert.That(build().Permissions, Is.EqualTo("r"), - "a shared link must be read-only — no write/delete/list") + Assert.Multiple(fun () -> + Assert.That(options.Metadata.Count, Is.EqualTo(1)) + Assert.That(options.Metadata.ContainsKey(ExpiryMetadataKey), Is.True) + Assert.That(ExpiryMetadataKey, Is.EqualTo("expiresOn")) + Assert.That( + options.Metadata[ExpiryMetadataKey], + Is.EqualTo("2030-01-02T01:04:05.0000000+00:00"))) [<Test>] - member _.``buildSasBuilder restricts the link to https (spr=https)``() = - Assert.That(build().Protocol, Is.EqualTo(SasProtocol.Https)) + member _.``upload declares UTF-8 HTML``() = + Assert.That( + (buildUploadOptions expiresOn).HttpHeaders.ContentType, + Is.EqualTo("text/html; charset=utf-8")) [<Test>] - member _.``buildSasBuilder carries the requested expiry``() = - Assert.That(build().ExpiresOn, Is.EqualTo(expiresOn)) + member _.``publisher expiry metadata satisfies the viewer wire contract``() = + let metadata = + (buildUploadOptions expiresOn).Metadata + |> Seq.map (fun pair -> pair.Key, pair.Value) + |> Map.ofSeq + let justBeforeExpiry = + expiresOn.ToUniversalTime().AddTicks(-1L) - [<Test>] - member _.``buildSasBuilder binds the container and blob name``() = - let b = buildSasBuilder "my-container" "abc/report.html" expiresOn - Assert.That(b.BlobContainerName, Is.EqualTo("my-container")) - Assert.That(b.BlobName, Is.EqualTo("abc/report.html")) + Assert.Multiple(fun () -> + Assert.That( + ExpiryMetadataKey, + Is.EqualTo(CanvasShareViewer.ShareExpiry.MetadataKey)) + Assert.That( + CanvasShareViewer.ShareExpiry.isLive + justBeforeExpiry + metadata, + Is.True)) [<TestFixture>] [<Category("Unit")>] [<Category("Fast")>] -type UserDelegationSigningTests() = - - let startsOn = DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero) - let expiresOn = startsOn.AddDays(7.0) - let delegationKey = - BlobsModelFactory.UserDelegationKey( - "object-id", - "tenant-id", - startsOn, - expiresOn, - "b", - "2025-11-05", - Convert.ToBase64String(Array.create 32 42uy)) +type ViewerUrlTests() = - [<Test>] - member _.``buildSignedBlobUrl applies the delegation identity and blob-scoped grant``() = - let blobUri = Uri("https://tmcanvasabc.blob.core.windows.net/canvas-shared/prefix/doc.html") - let signedUrl = - buildSignedBlobUrl - blobUri - "tmcanvasabc" - delegationKey - (buildSasBuilder "canvas-shared" "prefix/doc.html" expiresOn) - let signedUri = Uri signedUrl + let prefix = "0123456789AbCdEfGhIjKl" + [<Test>] + member _.``publisher naming satisfies the viewer wire contract``() = Assert.Multiple(fun () -> - Assert.That(signedUri.GetLeftPart(UriPartial.Path), Is.EqualTo(blobUri.AbsoluteUri)) - Assert.That(signedUri.Query, Does.Contain("skoid=object-id")) - Assert.That(signedUri.Query, Does.Contain("sktid=tenant-id")) - Assert.That(signedUri.Query, Does.Contain("sks=b")) - Assert.That(signedUri.Query, Does.Contain("sp=r")) - Assert.That(signedUri.Query, Does.Contain("spr=https")) - Assert.That(signedUri.Query, Does.Contain("sr=b"))) + Assert.That( + PrefixLength, + Is.EqualTo(CanvasShareViewer.SharePath.PrefixLength)) + Assert.That( + CanvasShareViewer.SharePath.tryCreate + (generatePrefix ()) + (leafName "nested/status.html") + |> Option.isSome, + Is.True)) + + [<Test>] + member _.``viewer URL uses the canonical deployed origin and clean c path``() = + let url = + buildViewerUrl + (Uri("https://treemon.azurewebsites.net")) + prefix + "status.html" + + Assert.That( + url, + Is.EqualTo( + $"https://treemon.azurewebsites.net/c/{prefix}/status.html")) + [<Test>] + member _.``viewer URL uses the configured HTTPS host``() = + let url = + buildViewerUrl + (Uri("https://isolated-viewer.test:7443")) + prefix + "status.html" + + Assert.That( + url, + Is.EqualTo( + $"https://isolated-viewer.test:7443/c/{prefix}/status.html")) + + [<Test>] + member _.``viewer URL percent-encodes the filename as one path segment``() = + let url = + buildViewerUrl + (Uri("https://viewer.test")) + prefix + "nested/Q3 report #1.html" + + Assert.That( + url, + Is.EqualTo( + "https://viewer.test/c/" + + prefix + + "/Q3%20report%20%231.html")) + + [<Test>] + member _.``viewer URL has no query fragment or Blob credential``() = + let url = + buildViewerUrl + (Uri("https://viewer.test")) + prefix + "status.html" + let uri = Uri url -// ── config reader (touches TREEMON_CONFIG_DIR: non-parallel) ─────────────────── + Assert.Multiple(fun () -> + Assert.That(uri.Query, Is.Empty) + Assert.That(uri.Fragment, Is.Empty) + Assert.That(url, Does.Not.Contain("?")) + Assert.That(url, Does.Not.Contain("sig=")) + Assert.That(url, Does.Not.Contain(".blob.core.windows.net"))) [<TestFixture>] [<Category("Unit")>] @@ -162,19 +202,25 @@ type CanvasShareConfigTests() = Assert.That(readCanvasShareConfig (), Is.EqualTo(defaultCanvasShareConfig))) [<Test>] - member _.``readCanvasShareConfig reads accountName, container and defaultExpiryDays``() = + member _.``readCanvasShareConfig reads every non-secret publisher setting``() = withTempConfigDir "canvas-share-config" (fun dir -> - seed dir """{ "canvasShare": { "accountName": "tmcanvasabc", "container": "shared-docs", "defaultExpiryDays": 3 } }""" + seed dir + """{ "canvasShare": { "accountName": "tmcanvasabc", "container": "shared-docs", "defaultExpiryDays": 3, "viewerBaseUrl": "https://treemon.azurewebsites.net" } }""" let config = readCanvasShareConfig () Assert.That(config.AccountName, Is.EqualTo(Some "tmcanvasabc")) Assert.That(config.Container, Is.EqualTo("shared-docs")) - Assert.That(config.DefaultExpiryDays, Is.EqualTo(3))) + Assert.That(config.DefaultExpiryDays, Is.EqualTo(3)) + Assert.That( + config.ViewerBaseUrl, + Is.EqualTo(Some(Uri("https://treemon.azurewebsites.net"))))) [<Test>] - member _.``readCanvasShareConfig has no accountName by default — that is what unconfigured means``() = + member _.``account and viewer URL have no defaults``() = withTempConfigDir "canvas-share-config" (fun dir -> seed dir """{ "canvasShare": { "container": "shared-docs" } }""" - Assert.That(readCanvasShareConfig().AccountName, Is.EqualTo(None))) + let config = readCanvasShareConfig () + Assert.That(config.AccountName, Is.EqualTo(None)) + Assert.That(config.ViewerBaseUrl, Is.EqualTo(None))) [<Test>] member _.``readCanvasShareConfig treats a blank accountName as absent``() = @@ -183,6 +229,36 @@ type CanvasShareConfigTests() = Assert.That(readCanvasShareConfig().AccountName, Is.EqualTo(None), "a whitespace-only account name must not be published to")) + [<Test>] + member _.``readCanvasShareConfig accepts a configurable HTTPS viewer URL``() = + withTempConfigDir "canvas-share-config" (fun dir -> + seed dir + """{ "canvasShare": { "viewerBaseUrl": " https://isolated-viewer.test:7443/base/ " } }""" + Assert.That( + readCanvasShareConfig().ViewerBaseUrl, + Is.EqualTo(Some(Uri("https://isolated-viewer.test:7443/base/"))))) + + [<TestCase("")>] + [<TestCase(" ")>] + [<TestCase("http://viewer.test")>] + [<TestCase("https:viewer.test")>] + [<TestCase("viewer.test")>] + [<TestCase("not a URL")>] + member _.``blank malformed or non-HTTPS viewer URL is unconfigured``(value: string) = + withTempConfigDir "canvas-share-config" (fun dir -> + seed dir + $"""{{ "canvasShare": {{ "viewerBaseUrl": "{value}" }} }}""" + Assert.That(readCanvasShareConfig().ViewerBaseUrl, Is.EqualTo(None))) + + [<TestCase("https://viewer.test?credential=no")>] + [<TestCase("https://viewer.test/#fragment")>] + [<TestCase("https://credential@viewer.test")>] + member _.``viewer base URL rejects credential query and fragment components``(value: string) = + withTempConfigDir "canvas-share-config" (fun dir -> + seed dir + $"""{{ "canvasShare": {{ "viewerBaseUrl": "{value}" }} }}""" + Assert.That(readCanvasShareConfig().ViewerBaseUrl, Is.EqualTo(None))) + [<Test>] member _.``readCanvasShareConfig defaults the expiry when only the container is set``() = withTempConfigDir "canvas-share-config" (fun dir -> @@ -197,35 +273,27 @@ type CanvasShareConfigTests() = seed dir """{ "canvasShare": { "container": " " } }""" Assert.That(readCanvasShareConfig().Container, Is.EqualTo(defaultCanvasShareConfig.Container))) - [<Test>] - member _.``readCanvasShareConfig ignores a non-positive expiry (would mint a dead link)``() = + [<TestCase(1)>] + [<TestCase(7)>] + [<TestCase(30)>] + member _.``readCanvasShareConfig accepts a bounded product lifetime``(days: int) = withTempConfigDir "canvas-share-config" (fun dir -> - seed dir """{ "canvasShare": { "defaultExpiryDays": 0 } }""" - Assert.That(readCanvasShareConfig().DefaultExpiryDays, - Is.EqualTo(defaultCanvasShareConfig.DefaultExpiryDays))) + seed dir + $"""{{ "canvasShare": {{ "defaultExpiryDays": {days} }} }}""" + Assert.That(readCanvasShareConfig().DefaultExpiryDays, Is.EqualTo(days))) - [<Test>] - member _.``readCanvasShareConfig ignores an expiry beyond the user-delegation-key limit``() = + [<TestCase(0)>] + [<TestCase(31)>] + member _.``readCanvasShareConfig rejects a lifetime outside one through thirty``(days: int) = withTempConfigDir "canvas-share-config" (fun dir -> - // A user delegation key lives at most 7 days; Azure refuses a longer window outright when - // the key is minted, so an over-long config value must fall back rather than fail at publish. - seed dir """{ "canvasShare": { "defaultExpiryDays": 30 } }""" + seed dir + $"""{{ "canvasShare": {{ "defaultExpiryDays": {days} }} }}""" Assert.That(readCanvasShareConfig().DefaultExpiryDays, Is.EqualTo(defaultCanvasShareConfig.DefaultExpiryDays))) [<Test>] - member _.``readCanvasShareConfig accepts the maximum bounded expiry``() = - withTempConfigDir "canvas-share-config" (fun dir -> - seed dir """{ "canvasShare": { "defaultExpiryDays": 7 } }""" - Assert.That(readCanvasShareConfig().DefaultExpiryDays, Is.EqualTo(maxCanvasShareExpiryDays))) - - [<Test>] - member _.``the expiry ceiling is Azure's 7-day user-delegation-key limit``() = - // Pinned deliberately: raising this constant would mint links Azure refuses to sign. - Assert.That(maxCanvasShareExpiryDays, Is.EqualTo(7)) - - -// ── unconfigured / credential gate ───────────────────────────────────────────── + member _.``the durable product expiry ceiling is thirty days``() = + Assert.That(maxCanvasShareExpiryDays, Is.EqualTo(30)) [<TestFixture>] [<Category("Unit")>] @@ -235,6 +303,9 @@ type CanvasShareConfigTests() = [<NonParallelizable>] type PublishConfigGateTests() = + let seed (dir: string) (json: string) = + File.WriteAllText(Path.Combine(dir, "config.json"), json) + [<Test>] member _.``serviceClient reuses one Azure authentication pipeline per account``() = let first = serviceClient "tmcanvasabc" @@ -242,10 +313,10 @@ type PublishConfigGateTests() = Assert.That(serviceClient "tmcanvasxyz", Is.Not.SameAs(first)) [<Test>] - member _.``publish returns the not-configured error when no account name is set``() = - // Without an account name there is nothing to publish to, so publish must fail closed - // BEFORE acquiring a credential or touching the network. - withTempConfigDir "canvas-share-publish" (fun _ -> + member _.``publish fails before network when account name is missing``() = + withTempConfigDir "canvas-share-publish" (fun dir -> + seed dir + """{ "canvasShare": { "viewerBaseUrl": "https://isolated-viewer.test" } }""" match runAsync (publish "doc.html" "<html></html>") with | Error msg -> Assert.That(msg, Is.EqualTo(notConfiguredMessage)) @@ -253,10 +324,20 @@ type PublishConfigGateTests() = "the error must tell the operator which config key to set") | Ok url -> Assert.Fail($"expected Error when unconfigured, got Ok {url}")) + [<Test>] + member _.``publish fails before network when viewer URL is missing``() = + withTempConfigDir "canvas-share-publish" (fun dir -> + seed dir + """{ "canvasShare": { "accountName": "network-must-not-be-contacted" } }""" + match runAsync (publish "doc.html" "<html></html>") with + | Error msg -> + Assert.That(msg, Is.EqualTo(notConfiguredMessage)) + Assert.That(msg, Does.Contain("canvasShare.viewerBaseUrl"), + "the error must tell the operator which config key to set") + | Ok url -> Assert.Fail($"expected Error when unconfigured, got Ok {url}")) + [<Test>] member _.``the not-configured message names no application-managed storage credential``() = - // Treemon stores no account key or connection string, so the message must not send an - // operator hunting for one. Assert.That(notConfiguredMessage, Does.Not.Contain("AZURE_STORAGE_CONNECTION_STRING")) Assert.That(notConfiguredMessage.ToLowerInvariant(), Does.Not.Contain("key")) From 0b0e34327c543b8560cb6df4b4697bfa2569bcaa Mon Sep 17 00:00:00 2001 From: Petr Pokorny <petr@innit.cz> Date: Mon, 17 Aug 2026 19:48:33 +0200 Subject: [PATCH 05/27] tm-canvas-safe-share-6eg Automate viewer deployment --- docs/canvas-share-viewer-deployment.md | 143 +++ docs/spec/canvas-sharing.md | 21 + docs/spec/worktree-monitor.md | 6 +- scripts/canvas-share-lifecycle-policy.json | 2 +- .../canvas-share-viewer-deployment/Azure.ps1 | 957 ++++++++++++++++++ .../canvas-share-viewer-deployment/Common.ps1 | 339 +++++++ scripts/deploy-canvas-share-viewer.ps1 | 159 +++ 7 files changed, 1623 insertions(+), 4 deletions(-) create mode 100644 docs/canvas-share-viewer-deployment.md create mode 100644 scripts/canvas-share-viewer-deployment/Azure.ps1 create mode 100644 scripts/canvas-share-viewer-deployment/Common.ps1 create mode 100644 scripts/deploy-canvas-share-viewer.ps1 diff --git a/docs/canvas-share-viewer-deployment.md b/docs/canvas-share-viewer-deployment.md new file mode 100644 index 00000000..669e3e34 --- /dev/null +++ b/docs/canvas-share-viewer-deployment.md @@ -0,0 +1,143 @@ +# Canvas Share Viewer Deployment + +This is a local operator workflow for an isolated, non-production Azure subscription. It creates +or reconciles the canonical viewer at exactly: + +```text +https://treemon.azurewebsites.net +``` + +The App Service name is fixed as `treemon`. The automation checks global name availability before +the first creation and stops if the name is unavailable; it never chooses a suffix or a custom +domain. + +## Prerequisites + +- PowerShell 7.4 or later, Azure CLI 2.48.1 or later, and .NET SDK 10 or later. +- Azure CLI signed in as the delegated publisher user, with the requested personal development + subscription selected: + + ```powershell + az login --tenant '<tenant-id>' + az account set --subscription '<subscription-name-or-id>' + ``` + +- Azure permissions to create resources, update the storage account, assign roles at the Blob + container scope, and disable App Service basic publishing credentials. Entra permissions must + allow creation or ownership of the dedicated app registration and its federated credential. +- An existing storage account configured in the machine-level Treemon config. The container may be + omitted to use `canvas-shared`; the deployment creates it through the ARM control plane when + needed: + + ```json + { + "canvasShare": { + "accountName": "<storage-account>", + "container": "canvas-shared", + "defaultExpiryDays": 7 + } + } + ``` + +The script reads the storage account and container from `~/.treemon/config.json` (or +`$TREEMON_CONFIG_DIR/config.json`) and uses the current Azure CLI user as the publisher. Storage +and publisher identifiers are therefore not additional command-line inputs. + +## Validate without changing Azure + +Run the read-only validation first: + +```powershell +.\scripts\deploy-canvas-share-viewer.ps1 ` + -Subscription '<personal-development-subscription>' ` + -Tenant '<tenant-id>' ` + -ResourceGroup '<non-production-resource-group>' ` + -Plan '<app-service-plan>' ` + -Identity '<viewer-managed-identity>' ` + -Registration '<viewer-app-registration>' ` + -ValidateOnly +``` + +Validation checks the selected subscription and tenant, the storage configuration, any existing +resources, global availability of `treemon` when the app does not yet exist, the lifecycle-policy +invariant, and a local Release publish of the viewer. It performs no Azure mutation and does not +write machine configuration. + +## Provision and deploy + +Remove `-ValidateOnly` to apply the same plan: + +```powershell +.\scripts\deploy-canvas-share-viewer.ps1 ` + -Subscription '<personal-development-subscription>' ` + -Tenant '<tenant-id>' ` + -ResourceGroup '<non-production-resource-group>' ` + -Plan '<app-service-plan>' ` + -Identity '<viewer-managed-identity>' ` + -Registration '<viewer-app-registration>' +``` + +The script is idempotent and is intended to be run a second time with the same values. It: + +1. Creates or reuses the non-production resource group, a B1 Linux App Service plan, the + user-assigned managed identity, and the fixed-name App Service. +2. Disables account-level Blob public access, creates the configured private container, and grants + the viewer `Storage Blob Data Reader` and the current publisher + `Storage Blob Data Contributor`, both at that container's exact ARM scope. +3. Creates or reuses one uniquely named, secret-free, current-tenant `AzureADMyOrg` app + registration and service principal. The registration accepts only the canonical App Service + callback. +4. Adds a federated credential whose subject is the managed identity principal. Easy Auth uses the + slot-sticky `OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID` setting and its + `clientSecretSettingName` sentinel, so no client secret is created. +5. Requires Easy Auth before requests reach the viewer, uses the tenant's v2 issuer, requests no + extra login scopes, requires HTTPS, and disables the token store. +6. Merges `expire-shared-canvas-docs` into the storage account's complete lifecycle policy while + preserving unrelated rules. Deletion starts only after more than 31 days, beyond the 30-day + maximum share lifetime. +7. Disables FTP and SCM basic publishing credentials, builds a ZIP locally, and deploys it with + `az webapp deploy`. Azure CLI therefore uses Microsoft Entra authentication rather than a + deployment credential. +8. Verifies the resulting control-plane configuration and then atomically sets: + + ```json + { + "canvasShare": { + "viewerBaseUrl": "https://treemon.azurewebsites.net" + } + } + ``` + + Existing `canvasShare` fields and unrelated machine-level settings are preserved. + +The automation does not invoke Treemon server lifecycle commands and does not bind any local +Treemon port. Temporary build and JSON files are deleted on exit. It does not request or print +access tokens, create deployment credentials, or read a publishing profile. + +## After deployment + +Federated credentials and Blob role assignments can take several minutes to propagate. A first +sign-in or Blob read that fails immediately after provisioning should be retried after propagation; +do not replace the managed-identity federation with a client secret. + +By default, every identity in the current workforce tenant can authenticate. If a smaller audience +is required, enable enterprise-application assignment and assign the intended users or groups in +Entra; keep the app registration single-tenant. + +The canonical App Service, plan, identity, app registration/service principal, federated +credential, RBAC assignments, and lifecycle policy are durable non-production resources. Do not +tear them down after verification. Verification cleanup is limited to uploaded document fixtures +and auxiliary resources created solely for permission-boundary probes. + +Useful read-only checks are: + +```powershell +az webapp show --name treemon --resource-group '<non-production-resource-group>' + +az rest --method get ` + --uri '/subscriptions/<subscription-id>/resourceGroups/<non-production-resource-group>/providers/Microsoft.Web/sites/treemon/config/authsettingsV2?api-version=2023-12-01' + +az storage account management-policy show ` + --account-name '<storage-account>' ` + --resource-group '<storage-resource-group>' +``` diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index 626954cd..99cc677d 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -195,6 +195,22 @@ remote-URL fetch that would otherwise be a working exfiltration channel. - Provisioning ensures the configured share container exists with anonymous access disabled before either container-scoped grant is applied; the publisher intentionally does not create containers at share time. +- `scripts/deploy-canvas-share-viewer.ps1` is the idempotent operator entry point. Its only + deployment-name inputs are subscription, tenant, resource group, plan, identity, and app + registration; it reads account/container from the machine-level `canvasShare` config and resolves + the delegated publisher as the current Azure CLI user. The fixed B1 Linux plan and any new + resource group use the storage account's Azure location. +- `-ValidateOnly` performs subscription/tenant, configuration, existing-resource, global-name, and + local-publish checks without changing Azure or machine configuration. An apply run reconciles + resources, merges the canvas rule into the account's complete lifecycle policy without removing + unrelated rules, deploys with `az webapp deploy` after SCM/FTP basic authentication is disabled, + verifies the resulting control-plane state, and writes the exact canonical `viewerBaseUrl` while + preserving every other machine setting. +- Easy Auth's `clientSecretSettingName` is the + `OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID` sentinel; the slot-sticky app setting with that name + contains the user-assigned identity's client ID. The registration's federated credential trusts + that identity's principal ID with the tenant v2 issuer and `api://AzureADTokenExchange` + audience. No client secret or extra login scope is created. - The canonical App Service, identity, Entra configuration, RBAC grants, and lifecycle policy remain deployed after verification. Verification removes only its document fixtures and any auxiliary resources created solely to prove the permission boundary. @@ -234,6 +250,8 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | Look the blob up by exact composed name, never by listing or prefix search | A share URL then reveals only its own document; no reachable code path can turn one link into an inventory of the container. | | Allow `unsafe-eval` only inside the contained document response | Shared canvases already support arbitrary inline JavaScript; preserving `eval`/`new Function` compatibility does not grant viewer-origin or network access because the sandbox and remaining CSP directives still deny both. | | Use `treemon.azurewebsites.net` rather than a custom domain or generated suffix | The Azure-provided hostname is short, TLS-enabled, requires no DNS ownership, and gives every shared document one stable origin for browser SSO. | +| Derive storage and publisher deployment inputs from machine/Azure CLI state | The existing `canvasShare` account/container and current delegated publisher are already the publisher's source of truth. Requiring them again as script arguments would permit a viewer and publisher to be provisioned against different containers or identities. | +| Merge the lifecycle rule instead of replacing the account policy | Azure lifecycle policies are whole-document resources. Preserving unrelated rules avoids destructive drift when the storage account has other lifecycle-managed data. | ## Key Files @@ -247,6 +265,9 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | `src/Server/HttpSecurity.fs` | Shared Remoting CSRF guard covering `shareCanvasDoc` (unchanged) | | `src/Client/CanvasPane.fs`, `CanvasState.fs`, `CanvasUpdate.fs`, `index.html` | Share button, `ShareState` phase machine, clipboard write and banner routing (unchanged) | | `src/CanvasShareViewer/` | New App Service viewer: shell route, content route, expiry check, sandbox/CSP, Easy Auth configuration | +| `scripts/deploy-canvas-share-viewer.ps1` | Idempotent non-production Azure provisioning, secret-free Easy Auth, Entra-authenticated ZIP deployment, validation, and machine-config update | +| `scripts/canvas-share-lifecycle-policy.json` | Container-filtered deletion rule starting after 31 days | +| `docs/canvas-share-viewer-deployment.md` | Local operator prerequisites, dry run, apply, and durable-resource guidance | ## Verification diff --git a/docs/spec/worktree-monitor.md b/docs/spec/worktree-monitor.md index c1a19e6b..ed42be4e 100644 --- a/docs/spec/worktree-monitor.md +++ b/docs/spec/worktree-monitor.md @@ -33,9 +33,9 @@ ### Configuration Store -Machine-level state persists in `~/.treemon/config.json` (or `$TREEMON_CONFIG_DIR` when set, for tests). `src/Server/GlobalConfig.fs` is the sole owner of that file — a single JSON store fronted by typed accessors, with these invariants: +Machine-level state persists in `~/.treemon/config.json` (or `$TREEMON_CONFIG_DIR` when set, for tests). `src/Server/GlobalConfig.fs` owns all runtime access to that file — a single JSON store fronted by typed accessors. The one operator-time exception is canvas-viewer provisioning, which adds the canonical non-secret `canvasShare.viewerBaseUrl` after a successful deployment and aborts rather than overwrite a concurrent change. The store has these invariants: -- **Single serialized writer, atomic on disk.** Every mutation funnels through one in-process lock and writes via a temp-file-then-replace; no write bypasses the lock, so concurrent updates can't interleave or leave a partially written file. +- **Single serialized runtime writer, atomic on disk.** Every server mutation funnels through one in-process lock and writes via a temp-file-then-replace, so concurrent runtime updates can't interleave or leave a partially written file. The provisioning exception likewise uses replace and verifies that the source text is unchanged immediately before it writes. - **Never destroy data.** An unparseable `config.json` is backed up to a timestamped `*.corrupt-<ts>` sibling before a fresh object is started, and each write touches only its own named keys — every unrelated key is left intact. - **Typed accessors over one store.** Watched roots (with the missing-vs-empty distinction the startup resolver depends on — see Multi-Repo above), canvas pane open/position, collapsed repos, last-viewed hashes, and the editor command/name reader are thin wrappers over the same locked store. @@ -320,7 +320,7 @@ After the burst, `lastRuns` is pre-populated and the normal sequential loop take - net10.0 with Fable pinned to 5.0.0: Fable 4.x deadlocks when the compiled project targets net10.0, so the client needs Fable 5 (which in turn requires Feliz 3 — the Feliz 2 compiler plugin targets the Fable 4 AST). Later Fable 5 releases each break the client: 5.1.0 made F# reflection report `option` as a union, which `Fable.SimpleJson` classifies before its option case, so every `option` field in a remoting response fails to deserialize; 5.5.0 does the same for `list` and additionally rejects `Fable.Remoting.MsgPack`'s `inline private` helpers with a check stricter than `fsc`'s own. 5.0.0 predates all three. Revisit when `Fable.SimpleJson` and `Fable.Remoting` publish fixes. - Windows Terminal per-window tracking via HWND: tabs aren't reliably addressable, one window per worktree is simple and predictable - Upstream remote auto-detection over config-only: `upstream` remote name is the universal convention for fork workflows; config override available for non-standard setups -- Watched roots are server-owned and restart-to-apply (not live-updated): `tm add`/`remove` persist to the global config and take effect on the next server (re)start (the `treemon.ps1` shims trigger it when prod is running). Chosen for simpler code — no per-root scheduler-state machinery; live application remains a clean future extension. The server is the single writer of `config.json` (with an internal write lock); the online-only CLI never writes config files, which removes the cross-process clobber hazard. +- Watched roots are server-owned and restart-to-apply (not live-updated): `tm add`/`remove` persist to the global config and take effect on the next server (re)start (the `treemon.ps1` shims trigger it when prod is running). Chosen for simpler code — no per-root scheduler-state machinery; live application remains a clean future extension. The online-only CLI never writes config files, which removes its cross-process clobber hazard; the canvas-viewer provisioning script is the only external writer and performs one concurrency-checked update of its non-secret URL. - `GlobalConfig` vs `TreemonConfig` — the machine-level `~/.treemon/config.json` and the repo-local `.treemon.json` (`autoSyncBranches`, `baseBranch`, `upstreamRemote`, `diffCategories`) are deliberately separate stores in separate modules, named so the machine-vs-repo scope is obvious and the two never collide. - Create-worktree prompt auto-launch is **fire-and-forget, server-side, and reuses `launchAction`**: repo root, provider, and the new path are all in scope on the server, so it orchestrates the launch there rather than via a client follow-up. A failed spawn is logged, not surfaced (the worktree already exists), and it launches even after a post-fork warning. Provider is read **directly** from the new worktree's `.treemon.json` (it isn't in scheduler state yet, so `resolveProvider` would return `None` there), and the worktree path is single-quote-escaped in `SessionManager.buildScript` so a path containing `'` can't break the launch script. - The create-prompt skill is **chosen per-create via a radio group** (offered skills come from the machine-level `worktreeSkills`; built-in **None** sends the prompt verbatim). The chosen skill rides the create request; the server wraps the prompt with `skillInvocation` for a named skill or launches it verbatim for None. The prompt (and skill) are single-quote-escaped at the CLI sink, so an odd skill value is a no-op for the tool, not an injection concern, making validation pure complication. diff --git a/scripts/canvas-share-lifecycle-policy.json b/scripts/canvas-share-lifecycle-policy.json index 0efad802..5cc6197f 100644 --- a/scripts/canvas-share-lifecycle-policy.json +++ b/scripts/canvas-share-lifecycle-policy.json @@ -11,7 +11,7 @@ }, "actions": { "baseBlob": { - "delete": { "daysAfterModificationGreaterThan": 8 } + "delete": { "daysAfterModificationGreaterThan": 31 } } } } diff --git a/scripts/canvas-share-viewer-deployment/Azure.ps1 b/scripts/canvas-share-viewer-deployment/Azure.ps1 new file mode 100644 index 00000000..42263d37 --- /dev/null +++ b/scripts/canvas-share-viewer-deployment/Azure.ps1 @@ -0,0 +1,957 @@ +function Get-ExactAppRegistration { + $registrations = @( + Invoke-AzJson -Arguments @( + 'ad', 'app', 'list', + '--display-name', $Registration) + | Where-Object displayName -CEQ $Registration + ) + + if ($registrations.Count -gt 1) { + throw "More than one Entra app registration is named '$Registration'. Use a unique dedicated registration name." + } + + if ($registrations.Count -eq 1) { + return Invoke-AzJson -Arguments @('ad', 'app', 'show', '--id', $registrations[0].appId) + } + + $null +} + +function Assert-RegistrationIsDedicated { + param([Parameter(Mandatory)][pscustomobject] $AppRegistration) + + if (@($AppRegistration.passwordCredentials).Count -gt 0) { + throw "Entra app registration '$Registration' has a client secret. Use a dedicated secret-free registration." + } + + $redirectUris = @($AppRegistration.web.redirectUris) + $unexpectedRedirectUris = @($redirectUris | Where-Object { $_ -cne $callbackUrl }) + + if ($unexpectedRedirectUris.Count -gt 0) { + throw "Entra app registration '$Registration' has redirect URIs other than the canonical App Service callback. Use a dedicated registration." + } +} + +function Get-ExistingResources { + param([Parameter(Mandatory)][string] $SubscriptionId) + + $group = Try-AzJson -Arguments @( + 'group', 'show', + '--name', $ResourceGroup, + '--subscription', $SubscriptionId) + + $planResource = + if ($null -eq $group) { + $null + } else { + Try-AzJson -Arguments @( + 'appservice', 'plan', 'show', + '--name', $Plan, + '--resource-group', $ResourceGroup, + '--subscription', $SubscriptionId) + } + + $identityResource = + if ($null -eq $group) { + $null + } else { + Try-AzJson -Arguments @( + 'identity', 'show', + '--name', $Identity, + '--resource-group', $ResourceGroup, + '--subscription', $SubscriptionId) + } + + $webApp = + if ($null -eq $group) { + $null + } else { + Try-AzJson -Arguments @( + 'webapp', 'show', + '--name', $appName, + '--resource-group', $ResourceGroup, + '--subscription', $SubscriptionId) + } + + [pscustomobject]@{ + Group = $group + Plan = $planResource + Identity = $identityResource + WebApp = $webApp + Registration = Get-ExactAppRegistration + } +} + +function Assert-ExistingResourceSafety { + param( + [Parameter(Mandatory)][pscustomobject] $Existing, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + $environmentTag = + if ($null -ne $Existing.Group -and + $null -ne $Existing.Group.tags -and + $null -ne $Existing.Group.tags.PSObject.Properties['environment']) { + [string] $Existing.Group.tags.PSObject.Properties['environment'].Value + } else { + '' + } + + if ($environmentTag -match '^(?i:prod|production)$') { + throw "Resource group '$ResourceGroup' is tagged as production. This automation is non-production only." + } + + if ($null -ne $Existing.Plan -and -not [bool] $Existing.Plan.reserved) { + throw "Existing App Service plan '$Plan' is not a Linux plan." + } + + if ($null -ne $Existing.WebApp) { + if ($null -eq $Existing.Plan) { + throw "Existing app '$appName' does not use the requested plan '$Plan'." + } + + if ([string] $Existing.WebApp.defaultHostName -cne 'treemon.azurewebsites.net') { + throw "Existing app '$appName' does not have the canonical hostname." + } + + if ([string] $Existing.WebApp.kind -notmatch '(^|,)linux($|,)') { + throw "Existing app '$appName' is not a Linux App Service." + } + + if (-not [string]::Equals( + [string] $Existing.WebApp.serverFarmId, + [string] $Existing.Plan.id, + [StringComparison]::OrdinalIgnoreCase)) { + throw "Existing app '$appName' does not use plan '$Plan'." + } + + if ($null -ne $Existing.Identity) { + Assert-WebAppIdentityIsDedicated ` + -WebApp $Existing.WebApp ` + -IdentityResourceId ([string] $Existing.Identity.id) + } elseif ($null -ne $Existing.WebApp.identity) { + throw "Existing app '$appName' has an identity, but the requested dedicated identity '$Identity' does not exist." + } + + Assert-NoClientSecretConfiguration -SubscriptionId $SubscriptionId + } else { + $availabilityBodyPath = [IO.Path]::GetTempFileName() + + try { + Write-JsonFile ` + -Value ([ordered]@{ + name = $appName + type = 'Microsoft.Web/sites' + }) ` + -Path $availabilityBodyPath + $availability = Invoke-AzJson -Arguments @( + 'rest', + '--method', 'post', + '--uri', "/subscriptions/$SubscriptionId/providers/Microsoft.Web/checknameavailability?api-version=2023-12-01", + '--body', "@$availabilityBodyPath") + } finally { + Remove-Item -LiteralPath $availabilityBodyPath -Force -ErrorAction SilentlyContinue + } + + if (-not [bool] $availability.nameAvailable) { + throw "Global App Service name '$appName' is unavailable. The deployment will not append a suffix or use another hostname." + } + } + + if ($null -ne $Existing.Registration) { + Assert-RegistrationIsDedicated -AppRegistration $Existing.Registration + } +} + +function Get-AzureContext { + $cloud = Invoke-AzJson -Arguments @('cloud', 'show') + if ([string] $cloud.name -cne 'AzureCloud') { + throw "The canonical azurewebsites.net deployment requires the AzureCloud environment. Current cloud: $($cloud.name)." + } + + $targetAccount = Invoke-AzJson -Arguments @( + 'account', 'show', + '--subscription', $Subscription) + $currentAccount = Invoke-AzJson -Arguments @('account', 'show') + + if (-not [string]::Equals( + [string] $targetAccount.id, + [string] $currentAccount.id, + [StringComparison]::OrdinalIgnoreCase)) { + throw "Azure CLI is not currently selecting the requested subscription. Run 'az account set --subscription <subscription>' and retry." + } + + if (-not [string]::Equals( + [string] $targetAccount.tenantId, + $Tenant, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'The requested tenant does not own the selected subscription.' + } + + if ([string] $targetAccount.state -cne 'Enabled') { + throw 'The selected subscription is not enabled.' + } + + if ([string] $currentAccount.user.type -cne 'user') { + throw 'Sign in to Azure CLI with the delegated publisher user before running this script.' + } + + $publisher = Invoke-AzJson -Arguments @('ad', 'signed-in-user', 'show') + + [pscustomobject]@{ + SubscriptionId = [string] $targetAccount.id + TenantId = [string] $targetAccount.tenantId + PublisherObjectId = [string] $publisher.id + } +} + +function Ensure-ResourceGroup { + param( + [AllowNull()][pscustomobject] $ExistingGroup, + [Parameter(Mandatory)][pscustomobject] $StorageAccount, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + if ($null -eq $ExistingGroup) { + Write-Step "Creating non-production resource group '$ResourceGroup'" + Invoke-AzNone -Arguments @( + 'group', 'create', + '--name', $ResourceGroup, + '--location', [string] $StorageAccount.location, + '--tags', 'environment=nonproduction', 'purpose=treemon-canvas-share', + '--subscription', $SubscriptionId) + } + + Invoke-AzJson -Arguments @( + 'group', 'show', + '--name', $ResourceGroup, + '--subscription', $SubscriptionId) +} + +function Ensure-PrivateContainer { + param( + [Parameter(Mandatory)][pscustomobject] $StorageAccount, + [Parameter(Mandatory)][string] $Container, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + Write-Step "Ensuring private Blob container '$Container'" + Invoke-AzNone -Arguments @( + 'storage', 'account', 'update', + '--ids', [string] $StorageAccount.id, + '--allow-blob-public-access', 'false', + '--subscription', $SubscriptionId) + + $existingContainer = Try-AzJson -Arguments @( + 'storage', 'container-rm', 'show', + '--storage-account', [string] $StorageAccount.id, + '--name', $Container, + '--subscription', $SubscriptionId) + + if ($null -eq $existingContainer) { + Invoke-AzNone -Arguments @( + 'storage', 'container-rm', 'create', + '--storage-account', [string] $StorageAccount.id, + '--name', $Container, + '--public-access', 'off', + '--subscription', $SubscriptionId) + } else { + Invoke-AzNone -Arguments @( + 'storage', 'container-rm', 'update', + '--storage-account', [string] $StorageAccount.id, + '--name', $Container, + '--public-access', 'off', + '--subscription', $SubscriptionId) + } + + "$($StorageAccount.id)/blobServices/default/containers/$Container" +} + +function Ensure-AppServicePlan { + param( + [AllowNull()][pscustomobject] $ExistingPlan, + [Parameter(Mandatory)][string] $Location, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + if ($null -eq $ExistingPlan) { + Write-Step "Creating Linux App Service plan '$Plan'" + Invoke-AzNone -Arguments @( + 'appservice', 'plan', 'create', + '--name', $Plan, + '--resource-group', $ResourceGroup, + '--location', $Location, + '--sku', 'B1', + '--is-linux', + '--subscription', $SubscriptionId) + } + + Invoke-AzJson -Arguments @( + 'appservice', 'plan', 'show', + '--name', $Plan, + '--resource-group', $ResourceGroup, + '--subscription', $SubscriptionId) +} + +function Ensure-ManagedIdentity { + param( + [AllowNull()][pscustomobject] $ExistingIdentity, + [Parameter(Mandatory)][string] $Location, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + if ($null -eq $ExistingIdentity) { + Write-Step "Creating user-assigned managed identity '$Identity'" + Invoke-AzNone -Arguments @( + 'identity', 'create', + '--name', $Identity, + '--resource-group', $ResourceGroup, + '--location', $Location, + '--subscription', $SubscriptionId) + } + + Invoke-AzJson -Arguments @( + 'identity', 'show', + '--name', $Identity, + '--resource-group', $ResourceGroup, + '--subscription', $SubscriptionId) +} + +function Get-AssignedIdentityIds { + param([AllowNull()][object] $UserAssignedIdentities) + + if ($null -eq $UserAssignedIdentities) { + return @() + } + + @($UserAssignedIdentities.PSObject.Properties.Name) +} + +function Assert-WebAppIdentityIsDedicated { + param( + [Parameter(Mandatory)][pscustomobject] $WebApp, + [Parameter(Mandatory)][string] $IdentityResourceId + ) + + if ($null -eq $WebApp.identity) { + return + } + + if ([string] $WebApp.identity.type -match 'SystemAssigned') { + throw "Existing app '$appName' has a system-assigned identity. Use the dedicated user-assigned identity only." + } + + $unexpectedIdentities = @( + Get-AssignedIdentityIds $WebApp.identity.userAssignedIdentities + | Where-Object { + -not [string]::Equals( + $_, + $IdentityResourceId, + [StringComparison]::OrdinalIgnoreCase) + } + ) + + if ($unexpectedIdentities.Count -gt 0) { + throw "Existing app '$appName' has a user-assigned identity other than '$Identity'." + } +} + +function Ensure-WebApp { + param( + [AllowNull()][pscustomobject] $ExistingWebApp, + [Parameter(Mandatory)][pscustomobject] $PlanResource, + [Parameter(Mandatory)][pscustomobject] $ManagedIdentity, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + if ($null -eq $ExistingWebApp) { + Write-Step "Creating fixed-name App Service '$appName'" + Invoke-AzNone -Arguments @( + 'webapp', 'create', + '--name', $appName, + '--resource-group', $ResourceGroup, + '--plan', $Plan, + '--runtime', 'DOTNETCORE:10.0', + '--assign-identity', [string] $ManagedIdentity.id, + '--basic-auth', 'Disabled', + '--subscription', $SubscriptionId) + } else { + Assert-WebAppIdentityIsDedicated ` + -WebApp $ExistingWebApp ` + -IdentityResourceId ([string] $ManagedIdentity.id) + } + + Invoke-AzNone -Arguments @( + 'webapp', 'identity', 'assign', + '--name', $appName, + '--resource-group', $ResourceGroup, + '--identities', [string] $ManagedIdentity.id, + '--subscription', $SubscriptionId) + + Invoke-AzNone -Arguments @( + 'webapp', 'update', + '--name', $appName, + '--resource-group', $ResourceGroup, + '--https-only', 'true', + '--subscription', $SubscriptionId) + + Invoke-AzNone -Arguments @( + 'webapp', 'config', 'set', + '--name', $appName, + '--resource-group', $ResourceGroup, + '--linux-fx-version', 'DOTNETCORE|10.0', + '--startup-file', 'dotnet CanvasShareViewer.dll', + '--ftps-state', 'Disabled', + '--http20-enabled', 'true', + '--min-tls-version', '1.2', + '--subscription', $SubscriptionId) + + $webApp = Invoke-AzJson -Arguments @( + 'webapp', 'show', + '--name', $appName, + '--resource-group', $ResourceGroup, + '--subscription', $SubscriptionId) + + if (-not [string]::Equals( + [string] $webApp.serverFarmId, + [string] $PlanResource.id, + [StringComparison]::OrdinalIgnoreCase)) { + throw "App '$appName' is not attached to plan '$Plan'." + } + + $webApp +} + +function Ensure-AppRegistration { + param([AllowNull()][pscustomobject] $ExistingRegistration) + + $appRegistration = + if ($null -eq $ExistingRegistration) { + Write-Step "Creating single-tenant Entra app registration '$Registration'" + Invoke-AzJson -Arguments @( + 'ad', 'app', 'create', + '--display-name', $Registration, + '--sign-in-audience', 'AzureADMyOrg', + '--web-redirect-uris', $callbackUrl) + } else { + $ExistingRegistration + } + + Assert-RegistrationIsDedicated -AppRegistration $appRegistration + + Invoke-AzNone -Arguments @( + 'ad', 'app', 'update', + '--id', [string] $appRegistration.appId, + '--sign-in-audience', 'AzureADMyOrg', + '--web-redirect-uris', $callbackUrl, + '--enable-access-token-issuance', 'false', + '--enable-id-token-issuance', 'false') + + $servicePrincipals = @( + Invoke-AzJson -Arguments @( + 'ad', 'sp', 'list', + '--filter', "appId eq '$($appRegistration.appId)'") + ) + + if ($servicePrincipals.Count -eq 0) { + Invoke-AzNone -Arguments @( + 'ad', 'sp', 'create', + '--id', [string] $appRegistration.appId) + } elseif ($servicePrincipals.Count -gt 1) { + throw "More than one service principal exists for Entra app registration '$Registration'." + } + + Invoke-AzJson -Arguments @('ad', 'app', 'show', '--id', [string] $appRegistration.appId) +} + +function Ensure-FederatedCredential { + param( + [Parameter(Mandatory)][pscustomobject] $AppRegistration, + [Parameter(Mandatory)][pscustomobject] $ManagedIdentity, + [Parameter(Mandatory)][string] $TenantId, + [Parameter(Mandatory)][string] $WorkingDirectory + ) + + Write-Step 'Ensuring managed-identity federation for Easy Auth' + $issuer = "https://login.microsoftonline.com/$TenantId/v2.0" + $credentialPath = Join-Path $WorkingDirectory 'federated-credential.json' + $existingCredentials = @( + Invoke-AzJson -Arguments @( + 'ad', 'app', 'federated-credential', 'list', + '--id', [string] $AppRegistration.appId) + ) + $matchingCredentials = @( + $existingCredentials | Where-Object name -CEQ $federatedCredentialName + ) + + if ($matchingCredentials.Count -gt 1) { + throw "More than one '$federatedCredentialName' federated credential exists." + } + + $credentialProperties = [ordered]@{ + issuer = $issuer + subject = [string] $ManagedIdentity.principalId + description = 'Trust the viewer managed identity as the Easy Auth client assertion.' + audiences = @('api://AzureADTokenExchange') + } + + if ($matchingCredentials.Count -eq 0) { + $createProperties = [ordered]@{ name = $federatedCredentialName } + foreach ($entry in $credentialProperties.GetEnumerator()) { + $createProperties[$entry.Key] = $entry.Value + } + + Write-JsonFile -Value $createProperties -Path $credentialPath + Invoke-AzNone -Arguments @( + 'ad', 'app', 'federated-credential', 'create', + '--id', [string] $AppRegistration.appId, + '--parameters', "@$credentialPath") + } else { + Write-JsonFile -Value $credentialProperties -Path $credentialPath + Invoke-AzNone -Arguments @( + 'ad', 'app', 'federated-credential', 'update', + '--id', [string] $AppRegistration.appId, + '--federated-credential-id', [string] $matchingCredentials[0].id, + '--parameters', "@$credentialPath") + } +} + +function Test-ExactRoleAssignment { + param( + [Parameter(Mandatory)][string] $PrincipalObjectId, + [Parameter(Mandatory)][string] $Role, + [Parameter(Mandatory)][string] $Scope, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + $assignments = @( + Invoke-AzJson -Arguments @( + 'role', 'assignment', 'list', + '--assignee-object-id', $PrincipalObjectId, + '--role', $Role, + '--scope', $Scope, + '--fill-principal-name', 'false', + '--subscription', $SubscriptionId) + | Where-Object { + [string]::Equals( + [string] $_.scope, + $Scope, + [StringComparison]::OrdinalIgnoreCase) + } + ) + + $assignments.Count -gt 0 +} + +function Ensure-RoleAssignment { + param( + [Parameter(Mandatory)][string] $PrincipalObjectId, + [Parameter(Mandatory)][ValidateSet('User', 'ServicePrincipal')][string] $PrincipalType, + [Parameter(Mandatory)][string] $Role, + [Parameter(Mandatory)][string] $Scope, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + if (-not (Test-ExactRoleAssignment ` + -PrincipalObjectId $PrincipalObjectId ` + -Role $Role ` + -Scope $Scope ` + -SubscriptionId $SubscriptionId)) { + Invoke-AzNone -Arguments @( + 'role', 'assignment', 'create', + '--assignee-object-id', $PrincipalObjectId, + '--assignee-principal-type', $PrincipalType, + '--role', $Role, + '--scope', $Scope, + '--subscription', $SubscriptionId) + } +} + +function Ensure-StorageAccess { + param( + [Parameter(Mandatory)][pscustomobject] $ManagedIdentity, + [Parameter(Mandatory)][string] $PublisherObjectId, + [Parameter(Mandatory)][string] $ContainerScope, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + Write-Step 'Ensuring container-scoped Blob data roles' + Ensure-RoleAssignment ` + -PrincipalObjectId ([string] $ManagedIdentity.principalId) ` + -PrincipalType ServicePrincipal ` + -Role $readerRole ` + -Scope $ContainerScope ` + -SubscriptionId $SubscriptionId + + Ensure-RoleAssignment ` + -PrincipalObjectId $PublisherObjectId ` + -PrincipalType User ` + -Role $contributorRole ` + -Scope $ContainerScope ` + -SubscriptionId $SubscriptionId +} + +function Get-CurrentManagementPolicy { + param( + [Parameter(Mandatory)][pscustomobject] $StorageAccount, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + Try-AzJson -Arguments @( + 'storage', 'account', 'management-policy', 'show', + '--account-name', [string] $StorageAccount.name, + '--resource-group', [string] $StorageAccount.resourceGroup, + '--subscription', $SubscriptionId) +} + +function Ensure-LifecyclePolicy { + param( + [Parameter(Mandatory)][pscustomobject] $StorageAccount, + [Parameter(Mandatory)][pscustomobject] $DesiredRule, + [Parameter(Mandatory)][string] $SubscriptionId, + [Parameter(Mandatory)][string] $WorkingDirectory + ) + + Write-Step 'Ensuring Blob lifecycle cleanup after the maximum share lifetime' + $currentPolicy = Get-CurrentManagementPolicy ` + -StorageAccount $StorageAccount ` + -SubscriptionId $SubscriptionId + $currentRules = @( + if ($null -eq $currentPolicy -or $null -eq $currentPolicy.policy) { + @() + } else { + @($currentPolicy.policy.rules) + } + ) + $unrelatedRules = @( + $currentRules | Where-Object name -CNE 'expire-shared-canvas-docs' + ) + $policy = [ordered]@{ + rules = @($unrelatedRules) + @($DesiredRule) + } + $policyPath = Join-Path $WorkingDirectory 'merged-lifecycle-policy.json' + Write-JsonFile -Value $policy -Path $policyPath + + Invoke-AzNone -Arguments @( + 'storage', 'account', 'management-policy', 'create', + '--account-name', [string] $StorageAccount.name, + '--resource-group', [string] $StorageAccount.resourceGroup, + '--policy', "@$policyPath", + '--subscription', $SubscriptionId) +} + +function Assert-NoClientSecretConfiguration { + param([Parameter(Mandatory)][string] $SubscriptionId) + + $settings = @( + Invoke-AzJson -Arguments @( + 'webapp', 'config', 'appsettings', 'list', + '--name', $appName, + '--resource-group', $ResourceGroup, + '--subscription', $SubscriptionId) + ) + + if ($settings | Where-Object name -CEQ 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET') { + throw "App '$appName' already contains an Easy Auth client-secret setting. Remove it before using the secret-free deployment." + } + + $configuredCredentialSetting = Try-AzJson -Arguments @( + 'rest', + '--method', 'get', + '--uri', "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Web/sites/$appName/config/authsettingsV2?api-version=2023-12-01", + '--query', 'properties.identityProviders.azureActiveDirectory.registration.clientSecretSettingName') + + if (-not [string]::IsNullOrWhiteSpace([string] $configuredCredentialSetting) -and + [string] $configuredCredentialSetting -cne $managedIdentityAssertionSetting) { + throw "App '$appName' is configured with an Easy Auth client secret. Remove it before using managed-identity federation." + } +} + +function Ensure-AppSettings { + param( + [Parameter(Mandatory)][string] $StorageAccountName, + [Parameter(Mandatory)][string] $Container, + [Parameter(Mandatory)][pscustomobject] $ManagedIdentity, + [Parameter(Mandatory)][string] $TenantId, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + Assert-NoClientSecretConfiguration -SubscriptionId $SubscriptionId + Write-Step 'Configuring non-secret viewer settings' + + Invoke-AzNone -Arguments @( + 'webapp', 'config', 'appsettings', 'set', + '--name', $appName, + '--resource-group', $ResourceGroup, + '--settings', + "CanvasShareViewer__StorageAccountName=$StorageAccountName", + "CanvasShareViewer__ShareContainer=$Container", + "AZURE_CLIENT_ID=$($ManagedIdentity.clientId)", + "WEBSITE_AUTH_AAD_ALLOWED_TENANTS=$TenantId", + '--subscription', $SubscriptionId) + + Invoke-AzNone -Arguments @( + 'webapp', 'config', 'appsettings', 'set', + '--name', $appName, + '--resource-group', $ResourceGroup, + '--slot-settings', + "$managedIdentityAssertionSetting=$($ManagedIdentity.clientId)", + '--subscription', $SubscriptionId) +} + +function Ensure-EasyAuth { + param( + [Parameter(Mandatory)][pscustomobject] $AppRegistration, + [Parameter(Mandatory)][string] $TenantId, + [Parameter(Mandatory)][string] $SubscriptionId, + [Parameter(Mandatory)][string] $WorkingDirectory + ) + + Write-Step 'Configuring required single-tenant Easy Auth' + $authSettings = [ordered]@{ + properties = [ordered]@{ + platform = [ordered]@{ + enabled = $true + runtimeVersion = '~2' + } + globalValidation = [ordered]@{ + requireAuthentication = $true + unauthenticatedClientAction = 'RedirectToLoginPage' + redirectToProvider = 'azureactivedirectory' + } + httpSettings = [ordered]@{ + requireHttps = $true + } + identityProviders = [ordered]@{ + azureActiveDirectory = [ordered]@{ + enabled = $true + registration = [ordered]@{ + clientId = [string] $AppRegistration.appId + clientSecretSettingName = $managedIdentityAssertionSetting + openIdIssuer = "https://login.microsoftonline.com/$TenantId/v2.0" + } + login = [ordered]@{ + loginParameters = @() + } + } + } + login = [ordered]@{ + tokenStore = [ordered]@{ + enabled = $false + } + } + } + } + $authSettingsPath = Join-Path $WorkingDirectory 'authsettings-v2.json' + Write-JsonFile -Value $authSettings -Path $authSettingsPath + + Invoke-AzNone -Arguments @( + 'rest', + '--method', 'put', + '--uri', "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Web/sites/$appName/config/authsettingsV2?api-version=2023-12-01", + '--body', "@$authSettingsPath") +} + +function Disable-BasicPublishingCredentials { + param([Parameter(Mandatory)][string] $SubscriptionId) + + Write-Step 'Disabling FTP and SCM basic publishing credentials' + + @('ftp', 'scm') | ForEach-Object { + Invoke-AzNone -Arguments @( + 'resource', 'update', + '--resource-group', $ResourceGroup, + '--name', $_, + '--namespace', 'Microsoft.Web', + '--resource-type', 'basicPublishingCredentialsPolicies', + '--parent', "sites/$appName", + '--set', 'properties.allow=false', + '--subscription', $SubscriptionId) + } +} + +function Deploy-Viewer { + param( + [Parameter(Mandatory)][string] $PackagePath, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + Write-Step 'Deploying the viewer with Microsoft Entra authentication' + Invoke-AzNone -Arguments @( + 'webapp', 'deploy', + '--name', $appName, + '--resource-group', $ResourceGroup, + '--src-path', $PackagePath, + '--type', 'zip', + '--clean', 'true', + '--restart', 'true', + '--async', 'false', + '--subscription', $SubscriptionId) +} + +function Assert-DeployedState { + param( + [Parameter(Mandatory)][pscustomobject] $StorageAccount, + [Parameter(Mandatory)][string] $Container, + [Parameter(Mandatory)][string] $ContainerScope, + [Parameter(Mandatory)][pscustomobject] $ManagedIdentity, + [Parameter(Mandatory)][string] $PublisherObjectId, + [Parameter(Mandatory)][pscustomobject] $AppRegistration, + [Parameter(Mandatory)][string] $TenantId, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + Write-Step 'Verifying deployed control-plane configuration' + $webApp = Invoke-AzJson -Arguments @( + 'webapp', 'show', + '--name', $appName, + '--resource-group', $ResourceGroup, + '--subscription', $SubscriptionId) + + if ([string] $webApp.defaultHostName -cne 'treemon.azurewebsites.net' -or + -not [bool] $webApp.httpsOnly) { + throw 'App Service hostname or HTTPS-only configuration is incorrect.' + } + + $currentStorageAccount = Invoke-AzJson -Arguments @( + 'storage', 'account', 'show', + '--ids', [string] $StorageAccount.id, + '--subscription', $SubscriptionId) + if ([bool] $currentStorageAccount.allowBlobPublicAccess) { + throw 'Storage account still permits public Blob access.' + } + + $currentContainer = Invoke-AzJson -Arguments @( + 'storage', 'container-rm', 'show', + '--storage-account', [string] $StorageAccount.id, + '--name', $Container, + '--subscription', $SubscriptionId) + $publicAccess = + if ($null -ne $currentContainer.PSObject.Properties['publicAccess']) { + [string] $currentContainer.PSObject.Properties['publicAccess'].Value + } else { + '' + } + if (-not [string]::IsNullOrWhiteSpace($publicAccess) -and + $publicAccess -notmatch '^(?i:none|off)$') { + throw "Blob container '$Container' still permits public access." + } + + Assert-WebAppIdentityIsDedicated ` + -WebApp $webApp ` + -IdentityResourceId ([string] $ManagedIdentity.id) + $assignedIdentityIds = Get-AssignedIdentityIds $webApp.identity.userAssignedIdentities + + if (-not ($assignedIdentityIds | Where-Object { + [string]::Equals( + $_, + [string] $ManagedIdentity.id, + [StringComparison]::OrdinalIgnoreCase) + })) { + throw "App '$appName' is missing managed identity '$Identity'." + } + + $settings = @( + Invoke-AzJson -Arguments @( + 'webapp', 'config', 'appsettings', 'list', + '--name', $appName, + '--resource-group', $ResourceGroup, + '--subscription', $SubscriptionId) + ) + $expectedSettings = [ordered]@{ + CanvasShareViewer__StorageAccountName = [string] $StorageAccount.name + CanvasShareViewer__ShareContainer = $Container + AZURE_CLIENT_ID = [string] $ManagedIdentity.clientId + WEBSITE_AUTH_AAD_ALLOWED_TENANTS = $TenantId + $managedIdentityAssertionSetting = [string] $ManagedIdentity.clientId + } + + foreach ($setting in $expectedSettings.GetEnumerator()) { + if ((Get-AppSettingValue -Settings $settings -Name $setting.Key) -cne $setting.Value) { + throw "App setting '$($setting.Key)' is missing or incorrect." + } + } + + if ($settings | Where-Object name -CEQ 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET') { + throw 'An Easy Auth client-secret setting is present.' + } + + $authSettings = Invoke-AzJson -Arguments @( + 'rest', + '--method', 'get', + '--uri', "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Web/sites/$appName/config/authsettingsV2?api-version=2023-12-01") + $azureAd = $authSettings.properties.identityProviders.azureActiveDirectory + + if (-not [bool] $authSettings.properties.platform.enabled -or + -not [bool] $authSettings.properties.globalValidation.requireAuthentication -or + [string] $authSettings.properties.globalValidation.unauthenticatedClientAction -cne 'RedirectToLoginPage' -or + -not [bool] $azureAd.enabled -or + [string] $azureAd.registration.clientId -cne [string] $AppRegistration.appId -or + [string] $azureAd.registration.clientSecretSettingName -cne $managedIdentityAssertionSetting -or + [string] $azureAd.registration.openIdIssuer -cne "https://login.microsoftonline.com/$TenantId/v2.0" -or + @($azureAd.login.loginParameters).Count -ne 0 -or + [bool] $authSettings.properties.login.tokenStore.enabled) { + throw 'Easy Auth is not configured for required, secret-free, single-tenant authentication with the token store disabled.' + } + + foreach ($policyName in @('ftp', 'scm')) { + $policy = Invoke-AzJson -Arguments @( + 'resource', 'show', + '--resource-group', $ResourceGroup, + '--name', $policyName, + '--namespace', 'Microsoft.Web', + '--resource-type', 'basicPublishingCredentialsPolicies', + '--parent', "sites/$appName", + '--subscription', $SubscriptionId) + + if ([bool] $policy.properties.allow) { + throw "$policyName basic publishing credentials are still enabled." + } + } + + if (-not (Test-ExactRoleAssignment ` + -PrincipalObjectId ([string] $ManagedIdentity.principalId) ` + -Role $readerRole ` + -Scope $ContainerScope ` + -SubscriptionId $SubscriptionId)) { + throw "Viewer identity is missing container-scoped '$readerRole'." + } + + if (-not (Test-ExactRoleAssignment ` + -PrincipalObjectId $PublisherObjectId ` + -Role $contributorRole ` + -Scope $ContainerScope ` + -SubscriptionId $SubscriptionId)) { + throw "Publisher identity is missing container-scoped '$contributorRole'." + } + + $managementPolicy = Get-CurrentManagementPolicy ` + -StorageAccount $StorageAccount ` + -SubscriptionId $SubscriptionId + $lifecycleRules = @( + $managementPolicy.policy.rules + | Where-Object name -CEQ 'expire-shared-canvas-docs' + ) + + if ($lifecycleRules.Count -ne 1 -or + [double] $lifecycleRules[0].definition.actions.baseBlob.delete.daysAfterModificationGreaterThan -lt $minimumLifecycleDays -or + @($lifecycleRules[0].definition.filters.prefixMatch).Count -ne 1 -or + [string] $lifecycleRules[0].definition.filters.prefixMatch[0] -cne "$Container/") { + throw 'Blob lifecycle policy does not preserve the full 30-day share lifetime.' + } + + $federatedCredentials = @( + Invoke-AzJson -Arguments @( + 'ad', 'app', 'federated-credential', 'list', + '--id', [string] $AppRegistration.appId) + | Where-Object name -CEQ $federatedCredentialName + ) + + if ($federatedCredentials.Count -ne 1 -or + [string] $federatedCredentials[0].issuer -cne "https://login.microsoftonline.com/$TenantId/v2.0" -or + [string] $federatedCredentials[0].subject -cne [string] $ManagedIdentity.principalId -or + @($federatedCredentials[0].audiences).Count -ne 1 -or + [string] $federatedCredentials[0].audiences[0] -cne 'api://AzureADTokenExchange') { + throw 'Managed-identity federated credential is missing or incorrect.' + } +} diff --git a/scripts/canvas-share-viewer-deployment/Common.ps1 b/scripts/canvas-share-viewer-deployment/Common.ps1 new file mode 100644 index 00000000..12be27f8 --- /dev/null +++ b/scripts/canvas-share-viewer-deployment/Common.ps1 @@ -0,0 +1,339 @@ +function Write-Step { + param([Parameter(Mandatory)][string] $Message) + + Write-Host "==> $Message" +} + +function Format-AzCommandName { + param([Parameter(Mandatory)][string[]] $Arguments) + + $argumentCount = [Math]::Min(3, $Arguments.Count) + if ($argumentCount -eq 0) { + return 'az' + } + + "az $($Arguments[0..($argumentCount - 1)] -join ' ')" +} + +function Invoke-AzRaw { + param( + [Parameter(Mandatory)][string[]] $Arguments, + [switch] $AllowFailure + ) + + $errorPath = [IO.Path]::GetTempFileName() + + try { + $output = @(& az @Arguments 2> $errorPath) + $exitCode = $LASTEXITCODE + $errorText = + if (Test-Path -LiteralPath $errorPath) { + [IO.File]::ReadAllText($errorPath).Trim() + } else { + '' + } + } finally { + Remove-Item -LiteralPath $errorPath -Force -ErrorAction SilentlyContinue + } + + if ($exitCode -ne 0 -and -not $AllowFailure) { + $detail = + if ([string]::IsNullOrWhiteSpace($errorText)) { + "exit code $exitCode" + } else { + $errorText + } + + throw "$(Format-AzCommandName $Arguments) failed: $detail" + } + + [pscustomobject]@{ + ExitCode = $exitCode + Output = ($output -join [Environment]::NewLine) + Error = $errorText + } +} + +function ConvertFrom-AzJson { + param( + [Parameter(Mandatory)][pscustomobject] $Result, + [Parameter(Mandatory)][string[]] $Arguments + ) + + if ([string]::IsNullOrWhiteSpace($Result.Output)) { + return $null + } + + try { + ConvertFrom-Json -InputObject $Result.Output -Depth 100 + } catch { + throw "$(Format-AzCommandName $Arguments) returned invalid JSON." + } +} + +function Invoke-AzJson { + param([Parameter(Mandatory)][string[]] $Arguments) + + $jsonArguments = @($Arguments) + @('--only-show-errors', '--output', 'json') + $result = Invoke-AzRaw -Arguments $jsonArguments + ConvertFrom-AzJson -Result $result -Arguments $Arguments +} + +function Try-AzJson { + param([Parameter(Mandatory)][string[]] $Arguments) + + $jsonArguments = @($Arguments) + @('--only-show-errors', '--output', 'json') + $result = Invoke-AzRaw -Arguments $jsonArguments -AllowFailure + + if ($result.ExitCode -eq 0) { + return ConvertFrom-AzJson -Result $result -Arguments $Arguments + } + + if ($result.Error -match '(?i)(not found|does not exist|could not be found|ResourceNotFound|ParentResourceNotFound|ManagementPolicyNotFound)') { + return $null + } + + $detail = + if ([string]::IsNullOrWhiteSpace($result.Error)) { + "exit code $($result.ExitCode)" + } else { + $result.Error + } + + throw "$(Format-AzCommandName $Arguments) failed: $detail" +} + +function Invoke-AzNone { + param([Parameter(Mandatory)][string[]] $Arguments) + + $noneArguments = @($Arguments) + @('--only-show-errors', '--output', 'none') + Invoke-AzRaw -Arguments $noneArguments | Out-Null +} + +function Get-TreemonConfigPath { + $configuredDirectory = [Environment]::GetEnvironmentVariable('TREEMON_CONFIG_DIR') + $configDirectory = + if ([string]::IsNullOrWhiteSpace($configuredDirectory)) { + Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)) '.treemon' + } else { + $configuredDirectory + } + + Join-Path $configDirectory 'config.json' +} + +function Read-TreemonCanvasShareConfig { + $path = Get-TreemonConfigPath + + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Treemon machine configuration was not found at '$path'. Configure canvasShare.accountName first." + } + + $raw = [IO.File]::ReadAllText($path) + + try { + $root = ConvertFrom-Json -InputObject $raw -AsHashtable -Depth 100 + } catch { + throw "Treemon machine configuration at '$path' is not valid JSON." + } + + if ($root -isnot [Collections.IDictionary] -or + -not $root.Contains('canvasShare') -or + $root['canvasShare'] -isnot [Collections.IDictionary]) { + throw "Treemon machine configuration must contain a canvasShare object with accountName." + } + + $canvasShare = $root['canvasShare'] + $accountName = + if ($canvasShare.Contains('accountName')) { + [string] $canvasShare['accountName'] + } else { + '' + } + + if ([string]::IsNullOrWhiteSpace($accountName)) { + throw "Treemon machine configuration must set canvasShare.accountName before viewer deployment." + } + + $container = + if ($canvasShare.Contains('container') -and + -not [string]::IsNullOrWhiteSpace([string] $canvasShare['container'])) { + ([string] $canvasShare['container']).Trim() + } else { + 'canvas-shared' + } + + [pscustomobject]@{ + Path = $path + Raw = $raw + Root = $root + AccountName = $accountName.Trim() + Container = $container + } +} + +function Set-TreemonViewerBaseUrl { + param( + [Parameter(Mandatory)][string] $ExpectedAccountName, + [Parameter(Mandatory)][string] $ExpectedContainer + ) + + $configuration = Read-TreemonCanvasShareConfig + + if ($configuration.AccountName -cne $ExpectedAccountName -or + $configuration.Container -cne $ExpectedContainer) { + throw 'Treemon canvasShare storage configuration changed during deployment. The viewer URL was not written.' + } + + $configuration.Root['canvasShare']['viewerBaseUrl'] = $viewerBaseUrl + $serialized = ConvertTo-Json -InputObject $configuration.Root -Depth 100 + $serialized = "$serialized$([Environment]::NewLine)" + $directory = Split-Path -Parent $configuration.Path + $temporaryPath = Join-Path $directory "config.json.viewer-$PID-$([Guid]::NewGuid().ToString('N')).tmp" + $backupPath = "$temporaryPath.bak" + + try { + [IO.File]::WriteAllText( + $temporaryPath, + $serialized, + [Text.UTF8Encoding]::new($false)) + + if ([IO.File]::ReadAllText($configuration.Path) -cne $configuration.Raw) { + throw 'Treemon machine configuration changed while it was being updated. Retry deployment to set viewerBaseUrl.' + } + + [IO.File]::Replace($temporaryPath, $configuration.Path, $backupPath, $true) + } finally { + Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $backupPath -Force -ErrorAction SilentlyContinue + } +} + +function Assert-Prerequisites { + if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + throw 'Azure CLI (az) is required.' + } + + if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { + throw '.NET SDK 10 or later is required.' + } + + if (-not (Test-Path -LiteralPath $viewerProject -PathType Leaf)) { + throw "Viewer project was not found at '$viewerProject'." + } + + if (-not (Test-Path -LiteralPath $lifecyclePolicyPath -PathType Leaf)) { + throw "Lifecycle policy was not found at '$lifecyclePolicyPath'." + } + + $azVersions = Invoke-AzJson -Arguments @('version') + $azVersion = [version] $azVersions.'azure-cli' + + if ($azVersion -lt [version] '2.48.1') { + throw "Azure CLI 2.48.1 or later is required for Microsoft Entra-authenticated App Service deployment. Found $azVersion." + } + + $dotnetVersionText = (& dotnet --version).Trim() + if ($LASTEXITCODE -ne 0) { + throw 'Could not read the installed .NET SDK version.' + } + + $dotnetVersion = [version] $dotnetVersionText.Split('-')[0] + if ($dotnetVersion.Major -lt 10) { + throw ".NET SDK 10 or later is required. Found $dotnetVersionText." + } +} + +function Get-LifecycleRule { + param([Parameter(Mandatory)][string] $Container) + + try { + $policy = Get-Content -LiteralPath $lifecyclePolicyPath -Raw | ConvertFrom-Json -Depth 100 + } catch { + throw "Lifecycle policy at '$lifecyclePolicyPath' is not valid JSON." + } + + $rules = @($policy.rules) + $matchingRules = @($rules | Where-Object name -CEQ 'expire-shared-canvas-docs') + + if ($matchingRules.Count -ne 1) { + throw "Lifecycle policy must contain exactly one 'expire-shared-canvas-docs' rule." + } + + $rule = $matchingRules[0] + $deleteDays = [double] $rule.definition.actions.baseBlob.delete.daysAfterModificationGreaterThan + + if ($deleteDays -lt $minimumLifecycleDays) { + throw "Lifecycle deletion must start after at least $minimumLifecycleDays days." + } + + $rule.definition.filters.prefixMatch = @("$Container/") + $rule +} + +function Get-StorageAccount { + param( + [Parameter(Mandatory)][string] $AccountName, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + $account = Try-AzJson -Arguments @( + 'storage', 'account', 'show', + '--name', $AccountName, + '--subscription', $SubscriptionId) + + if ($null -eq $account) { + throw "Storage account '$AccountName' was not found in the selected subscription." + } + + $account +} + +function New-ViewerPackage { + param([Parameter(Mandatory)][string] $WorkingDirectory) + + $publishDirectory = Join-Path $WorkingDirectory 'publish' + $packagePath = Join-Path $WorkingDirectory 'canvas-share-viewer.zip' + + Write-Step 'Building the viewer deployment package' + & dotnet publish $viewerProject ` + --configuration Release ` + --output $publishDirectory ` + --nologo ` + --verbosity minimal + + if ($LASTEXITCODE -ne 0) { + throw 'CanvasShareViewer publish failed.' + } + + Compress-Archive ` + -Path (Join-Path $publishDirectory '*') ` + -DestinationPath $packagePath ` + -CompressionLevel Optimal + + $packagePath +} + +function Write-JsonFile { + param( + [Parameter(Mandatory)][object] $Value, + [Parameter(Mandatory)][string] $Path + ) + + $json = ConvertTo-Json -InputObject $Value -Depth 100 + [IO.File]::WriteAllText($Path, $json, [Text.UTF8Encoding]::new($false)) +} + +function Get-AppSettingValue { + param( + [Parameter(Mandatory)][object[]] $Settings, + [Parameter(Mandatory)][string] $Name + ) + + $matches = @($Settings | Where-Object name -CEQ $Name) + if ($matches.Count -ne 1) { + return $null + } + + [string] $matches[0].value +} diff --git a/scripts/deploy-canvas-share-viewer.ps1 b/scripts/deploy-canvas-share-viewer.ps1 new file mode 100644 index 00000000..ebc671ab --- /dev/null +++ b/scripts/deploy-canvas-share-viewer.ps1 @@ -0,0 +1,159 @@ +#requires -Version 7.4 + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Subscription, + + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')] + [string] $Tenant, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ResourceGroup, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Plan, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Identity, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Registration, + + [switch] $ValidateOnly +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$appName = 'treemon' +$viewerBaseUrl = 'https://treemon.azurewebsites.net' +$callbackUrl = "$viewerBaseUrl/.auth/login/aad/callback" +$federatedCredentialName = 'treemon-easy-auth' +$managedIdentityAssertionSetting = 'OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID' +$readerRole = 'Storage Blob Data Reader' +$contributorRole = 'Storage Blob Data Contributor' +$minimumLifecycleDays = 31 +$repoRoot = Split-Path -Parent $PSScriptRoot +$viewerProject = Join-Path $repoRoot 'src' 'CanvasShareViewer' 'CanvasShareViewer.fsproj' +$lifecyclePolicyPath = Join-Path $PSScriptRoot 'canvas-share-lifecycle-policy.json' +$deploymentSupportDirectory = Join-Path $PSScriptRoot 'canvas-share-viewer-deployment' + +. (Join-Path $deploymentSupportDirectory 'Common.ps1') +. (Join-Path $deploymentSupportDirectory 'Azure.ps1') + +Write-Step 'Validating local tools and repository inputs' +Assert-Prerequisites +$treemonConfig = Read-TreemonCanvasShareConfig +$desiredLifecycleRule = Get-LifecycleRule -Container $treemonConfig.Container + +Write-Step 'Validating Azure subscription, tenant, and delegated publisher' +$azureContext = Get-AzureContext +$storageAccount = Get-StorageAccount ` + -AccountName $treemonConfig.AccountName ` + -SubscriptionId $azureContext.SubscriptionId +$existingResources = Get-ExistingResources -SubscriptionId $azureContext.SubscriptionId +Assert-ExistingResourceSafety ` + -Existing $existingResources ` + -SubscriptionId $azureContext.SubscriptionId + +$workingDirectory = Join-Path ([IO.Path]::GetTempPath()) "treemon-viewer-$PID-$([Guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $workingDirectory | Out-Null + +try { + $packagePath = New-ViewerPackage -WorkingDirectory $workingDirectory + + if ($ValidateOnly) { + Write-Host '' + Write-Host 'Validation succeeded. No Azure resource or machine configuration was changed.' + Write-Host "An apply run will ensure the fixed viewer at $viewerBaseUrl, secret-free Easy Auth, container-scoped roles, and lifecycle deletion after at least $minimumLifecycleDays days." + return + } + + $group = Ensure-ResourceGroup ` + -ExistingGroup $existingResources.Group ` + -StorageAccount $storageAccount ` + -SubscriptionId $azureContext.SubscriptionId + $containerScope = Ensure-PrivateContainer ` + -StorageAccount $storageAccount ` + -Container $treemonConfig.Container ` + -SubscriptionId $azureContext.SubscriptionId + $planResource = Ensure-AppServicePlan ` + -ExistingPlan $existingResources.Plan ` + -Location ([string] $group.location) ` + -SubscriptionId $azureContext.SubscriptionId + $managedIdentity = Ensure-ManagedIdentity ` + -ExistingIdentity $existingResources.Identity ` + -Location ([string] $group.location) ` + -SubscriptionId $azureContext.SubscriptionId + $webApp = Ensure-WebApp ` + -ExistingWebApp $existingResources.WebApp ` + -PlanResource $planResource ` + -ManagedIdentity $managedIdentity ` + -SubscriptionId $azureContext.SubscriptionId + $appRegistration = Ensure-AppRegistration ` + -ExistingRegistration $existingResources.Registration + + Ensure-FederatedCredential ` + -AppRegistration $appRegistration ` + -ManagedIdentity $managedIdentity ` + -TenantId $azureContext.TenantId ` + -WorkingDirectory $workingDirectory + Ensure-StorageAccess ` + -ManagedIdentity $managedIdentity ` + -PublisherObjectId $azureContext.PublisherObjectId ` + -ContainerScope $containerScope ` + -SubscriptionId $azureContext.SubscriptionId + Ensure-LifecyclePolicy ` + -StorageAccount $storageAccount ` + -DesiredRule $desiredLifecycleRule ` + -SubscriptionId $azureContext.SubscriptionId ` + -WorkingDirectory $workingDirectory + Ensure-AppSettings ` + -StorageAccountName $treemonConfig.AccountName ` + -Container $treemonConfig.Container ` + -ManagedIdentity $managedIdentity ` + -TenantId $azureContext.TenantId ` + -SubscriptionId $azureContext.SubscriptionId + Ensure-EasyAuth ` + -AppRegistration $appRegistration ` + -TenantId $azureContext.TenantId ` + -SubscriptionId $azureContext.SubscriptionId ` + -WorkingDirectory $workingDirectory + Disable-BasicPublishingCredentials ` + -SubscriptionId $azureContext.SubscriptionId + Deploy-Viewer ` + -PackagePath $packagePath ` + -SubscriptionId $azureContext.SubscriptionId + Assert-DeployedState ` + -StorageAccount $storageAccount ` + -Container $treemonConfig.Container ` + -ContainerScope $containerScope ` + -ManagedIdentity $managedIdentity ` + -PublisherObjectId $azureContext.PublisherObjectId ` + -AppRegistration $appRegistration ` + -TenantId $azureContext.TenantId ` + -SubscriptionId $azureContext.SubscriptionId + + Write-Step 'Setting the canonical viewer URL in Treemon machine configuration' + Set-TreemonViewerBaseUrl ` + -ExpectedAccountName $treemonConfig.AccountName ` + -ExpectedContainer $treemonConfig.Container + + $updatedConfig = Read-TreemonCanvasShareConfig + if ([string] $updatedConfig.Root['canvasShare']['viewerBaseUrl'] -cne $viewerBaseUrl) { + throw 'Treemon machine configuration does not contain the canonical viewerBaseUrl.' + } + + Write-Host '' + Write-Host "Deployment complete: $viewerBaseUrl" + Write-Host 'The canonical App Service, identity, Entra configuration, RBAC grants, and lifecycle policy remain deployed.' +} finally { + Remove-Item -LiteralPath $workingDirectory -Recurse -Force -ErrorAction SilentlyContinue +} From 4f4ac35d228b21b5de71015d38f519015ac0101a Mon Sep 17 00:00:00 2001 From: Petr Pokorny <petr@innit.cz> Date: Tue, 18 Aug 2026 09:12:35 +0200 Subject: [PATCH 06/27] [tm-canvas-safe-share-xur] Run focused code review Record the user's decisions on the review's six Needs Your Decision findings: a tenant-wide authenticated audience with no enterprise-application assignment, an identical not-found response with no elapsed-time guarantee (malformed paths may fail before storage access), server-independent deployment config writes, and duplicated publisher/viewer contract constants pinned by compatibility tests. --- docs/canvas-share-viewer-deployment.md | 4 +++ docs/spec/canvas-sharing.md | 46 ++++++++++++++++---------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/docs/canvas-share-viewer-deployment.md b/docs/canvas-share-viewer-deployment.md index 669e3e34..38b35f4c 100644 --- a/docs/canvas-share-viewer-deployment.md +++ b/docs/canvas-share-viewer-deployment.md @@ -110,6 +110,10 @@ The script is idempotent and is intended to be run a second time with the same v Existing `canvasShare` fields and unrelated machine-level settings are preserved. + Run the apply step while no Treemon instance is writing machine configuration -- the script + reads, updates, and atomically replaces `config.json` itself rather than going through a + running server, so a settings change made in the UI at the same moment could be overwritten. + The automation does not invoke Treemon server lifecycle commands and does not bind any local Treemon port. Temporary build and JSON files are deleted on exit. It does not request or print access tokens, create deployment credentials, or read a publishing profile. diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index 99cc677d..dd08808e 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -6,8 +6,9 @@ plain browser after signing in with Microsoft Entra. No SAS, account key, or other bearer credential is ever generated or returned to the recipient. - Real authorization, not just secrecy: the URL's opaque segment narrows which document a - signed-in identity can reach, but Entra sign-in (and any enterprise-app assignment) is what - actually gates access. A leaked link alone is not sufficient to view the document. + signed-in identity can reach, but Entra sign-in is what actually gates access. A link leaked + outside the tenant is not sufficient to view the document; inside the tenant, the audience is + every authenticated member and B2B guest holding it. - A bounded, view-time-enforced lifetime: a document stops being viewable at its configured expiry even if the underlying blob has not yet been swept up by storage lifecycle cleanup. - Contained interactivity: the recipient still sees the document's live HTML/JS rather than a @@ -60,8 +61,12 @@ wrote as blob metadata, and renders the document only when the blob exists, the metadata is well-formed, and the expiry has not passed. - Missing, malformed, and expired share paths return the same generic not-found response. Easy - Auth handles unauthenticated or unassigned identities before application code runs and never - reveals whether the requested share exists. + Auth handles unauthenticated identities before application code runs and never reveals whether + the requested share exists. +- The audience is tenant-wide: any identity the tenant issuer authenticates -- a current-tenant + user or an invited B2B guest -- may view a share whose link it holds. Enterprise-application + assignment is deliberately not required, so possession of the link plus a tenant sign-in is the + access boundary; identities outside the tenant are denied. - The rendered page is a minimal shell that embeds the document itself from a separate content route inside a sandboxed iframe (see Technical Approach); the shell carries no document content and no privileged API of its own. @@ -120,9 +125,9 @@ internet-facing component this feature adds. - Easy Auth is configured for the workforce, current-tenant, single-tenant Entra registration with authentication required at the platform level, so an unauthenticated request never reaches - application code. A narrower enterprise-application assignment (specific users or groups) can - constrain the audience below "whole tenant" where needed. The registration authenticates to Easy - Auth via a managed-identity federated credential rather than a long-lived client secret. + application code. Assignment is not required on the enterprise application: every identity the + tenant authenticates, including B2B guests, passes the gate. The registration authenticates to + Easy Auth via a managed-identity federated credential rather than a long-lived client secret. - Two routes divide responsibility: a shell route (`/c/<opaque-prefix>/<filename>`) validates the request and expiry and renders a minimal HTML page; a content route (`/c/<opaque-prefix>/<filename>/content`) streams the document bytes and is the only thing the @@ -131,11 +136,11 @@ - Each route re-validates the segments and re-checks expiry against blob metadata on its own; the content route never trusts that the shell already checked. Otherwise the content route would be an unguarded bypass for an expired or malformed share whose URL the recipient still holds. -- A matched route performs one exact read before it collapses path validity, Blob existence, and - expiry into the single not-found outcome: a valid path reads its exact `<prefix>/<filename>`, - while malformed segments read one fixed, non-servable probe name so untrusted dot segments never - reach Blob URI construction. This keeps malformed, missing, and expired requests on the same - application-level ordering. +- A matched route collapses path validity, Blob existence, and expiry into the single not-found + outcome: a valid path performs one exact read of `<prefix>/<filename>`, while malformed segments + resolve to not-found before any storage access, so untrusted dot segments never reach Blob URI + construction. Every not-found path then emits the identical response through the same + application-level ordering; only elapsed time may differ. - The shell's iframe uses `sandbox="allow-scripts"` only -- it omits `allow-same-origin`, `allow-forms`, `allow-popups`, and `allow-top-navigation`, so the embedded document's script can run but cannot read the viewer's cookies or storage, submit forms, open popups, or navigate the @@ -165,7 +170,7 @@ only things they must agree on. They are pinned here rather than discovered per | Expiry metadata | Blob metadata key `expiresOn`, value ISO-8601 UTC round-trip (`DateTimeOffset` `"o"`). A value that is absent or unparseable is malformed, not "never expires". | | Opaque prefix segment | Exactly `CanvasShare.PrefixLength` (22) base62 characters (`[0-9A-Za-z]`). | | Filename segment | One path segment ending `.html`; no `/`, `\`, or `..`. This is the publisher's `leafName` output; URL composers percent-encode it as one URI path segment while Blob lookup uses the decoded filename. | -| Not-found response | HTTP 404 with one fixed, content-free body, byte-identical for malformed, missing, and expired shares -- no header, status, or timing distinguishes them. | +| Not-found response | HTTP 404 with one fixed, content-free body, byte-identical for malformed, missing, and expired shares: identical status, headers, body, and response-emitting order. Elapsed time is not equalized -- a malformed path may be rejected before any storage access. | Response headers, by route: @@ -229,8 +234,10 @@ remote-URL fetch that would otherwise be a working exfiltration channel. - The content route's CSP and security headers apply regardless of authentication state, so even script that does execute inside the sandbox cannot reach the network or leak referrer information. -- Missing, malformed, and expired share paths are indistinguishable to an authenticated caller. - Easy Auth rejects unauthenticated or unassigned identities without revealing path existence. +- Missing, malformed, and expired share paths return an indistinguishable response -- same status, + headers, and body -- to an authenticated caller; only response latency may differ, which is an + accepted residual signal rather than a guarantee. Easy Auth rejects identities the tenant does + not authenticate without revealing path existence. - The Remoting CSRF guard continues to protect the publish call itself: a forged cross-origin `shareCanvasDoc` request from the operator's browser is rejected before any Azure I/O, the same as every other `IWorktreeApi` state-changing endpoint. @@ -252,6 +259,10 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | Use `treemon.azurewebsites.net` rather than a custom domain or generated suffix | The Azure-provided hostname is short, TLS-enabled, requires no DNS ownership, and gives every shared document one stable origin for browser SSO. | | Derive storage and publisher deployment inputs from machine/Azure CLI state | The existing `canvasShare` account/container and current delegated publisher are already the publisher's source of truth. Requiring them again as script arguments would permit a viewer and publisher to be provisioned against different containers or identities. | | Merge the lifecycle rule instead of replacing the account policy | Azure lifecycle policies are whole-document resources. Preserving unrelated rules avoids destructive drift when the storage account has other lifecycle-managed data. | +| Share with the whole tenant instead of requiring enterprise-application assignment | Sharing is link-driven and ad hoc; a maintained assignment list would lock out the colleagues and guests a link is handed to, while the unguessable path, tenant sign-in, and expiry already bound exposure. | +| Guarantee an identical not-found response but not identical timing | Status, headers, body, and emission order are what an authenticated recipient can compare reliably; equalizing elapsed time would need a threat model, a maximum blob size, and padding, which is disproportionate to the leaked fact that a path once existed. | +| Let the deployment script write `~/.treemon/config.json` directly instead of through the running server | Provisioning must work with no Treemon instance running, and a server RPC or shared cross-process lock would add permanent coupling for a rare operator step; the script re-reads, replaces atomically, and preserves every other setting, and the operator runs it while Treemon is not writing config. | +| Keep the publisher/viewer wire contract as pinned constants on both sides | The protocol is a handful of literals across two independently deployed apps, where a shared module would add coupling without preventing version skew; fixed-fixture compatibility tests catch drift at build time instead. | ## Key Files @@ -274,8 +285,9 @@ remote-URL fetch that would otherwise be a working exfiltration channel. - The copied URL is clean: no SAS, signature, or other query-string token of any kind. - The deployed viewer and every returned share URL use `https://treemon.azurewebsites.net`; provisioning never substitutes a suffixed hostname. -- Entra redirect/allow/deny: an anonymous request redirects to sign-in; an allowed tenant identity - views the document; an unassigned or external identity is denied. +- Entra redirect/allow/deny: an anonymous request redirects to sign-in; any identity the tenant + authenticates -- member or B2B guest -- views the document; an identity outside the tenant is + denied. - The viewer's managed identity can read the share container via Blob storage and nothing beyond it. - A document is denied immediately once its metadata expiry has passed, before any lifecycle From 56465219696f2625d4090049de1b30f19c8cb8c3 Mon Sep 17 00:00:00 2001 From: Petr Pokorny <petr@innit.cz> Date: Tue, 18 Aug 2026 09:58:59 +0200 Subject: [PATCH 07/27] tm-canvas-safe-share-tjo Reject broader viewer Blob access Add a fail-closed audit of the viewer identity's effective Blob-read RBAC. It enumerates direct, group-derived, and parent-scope-inherited assignments, resolves each role definition's dataActions/notDataActions, and rejects any Blob-read grant whose scope is not the share container or a descendant. The audit runs before any Azure mutation when the identity already exists and again during deployed-state verification; it reports but never deletes the offending assignment. Adds mocked regression tests wired into CI and updates the spec and operator docs. --- .github/workflows/ci.yml | 4 + docs/canvas-share-viewer-deployment.md | 19 +- docs/spec/canvas-sharing.md | 26 +- .../canvas-share-viewer-deployment/Azure.ps1 | 18 +- .../canvas-share-viewer-deployment/Common.ps1 | 4 +- .../ViewerBlobAccess.Tests.ps1 | 302 ++++++++++++++++++ .../ViewerBlobAccess.ps1 | 226 +++++++++++++ scripts/deploy-canvas-share-viewer.ps1 | 12 + 8 files changed, 596 insertions(+), 15 deletions(-) create mode 100644 scripts/canvas-share-viewer-deployment/ViewerBlobAccess.Tests.ps1 create mode 100644 scripts/canvas-share-viewer-deployment/ViewerBlobAccess.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ac49b6a..d471e30a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,5 +38,9 @@ jobs: - name: Test extensions run: npm run test:extension + - name: Test canvas share deployment + shell: pwsh + run: ./scripts/canvas-share-viewer-deployment/ViewerBlobAccess.Tests.ps1 + - name: Test .NET run: dotnet test src/Tests/Tests.fsproj --filter "Category!=Local" --no-build --verbosity normal diff --git a/docs/canvas-share-viewer-deployment.md b/docs/canvas-share-viewer-deployment.md index 38b35f4c..93390f69 100644 --- a/docs/canvas-share-viewer-deployment.md +++ b/docs/canvas-share-viewer-deployment.md @@ -13,7 +13,7 @@ domain. ## Prerequisites -- PowerShell 7.4 or later, Azure CLI 2.48.1 or later, and .NET SDK 10 or later. +- PowerShell 7.4 or later, Azure CLI 2.72.0 or later, and .NET SDK 10 or later. - Azure CLI signed in as the delegated publisher user, with the requested personal development subscription selected: @@ -25,6 +25,8 @@ domain. - Azure permissions to create resources, update the storage account, assign roles at the Blob container scope, and disable App Service basic publishing credentials. Entra permissions must allow creation or ownership of the dedicated app registration and its federated credential. + The operator must also be able to list the viewer identity's role assignments throughout the + subscription and inherited parent scopes and read their role definitions. - An existing storage account configured in the machine-level Treemon config. The container may be omitted to use `canvas-shared`; the deployment creates it through the ARM control plane when needed: @@ -60,8 +62,10 @@ Run the read-only validation first: Validation checks the selected subscription and tenant, the storage configuration, any existing resources, global availability of `treemon` when the app does not yet exist, the lifecycle-policy -invariant, and a local Release publish of the viewer. It performs no Azure mutation and does not -write machine configuration. +invariant, and a local Release publish of the viewer. If the requested managed identity already +exists, validation also resolves its direct and inherited role assignments and fails when any +effective Blob-read data action is scoped outside the configured share container. Group-derived +assignments are included. It performs no Azure mutation and does not write machine configuration. ## Provision and deploy @@ -83,7 +87,9 @@ The script is idempotent and is intended to be run a second time with the same v user-assigned managed identity, and the fixed-name App Service. 2. Disables account-level Blob public access, creates the configured private container, and grants the viewer `Storage Blob Data Reader` and the current publisher - `Storage Blob Data Contributor`, both at that container's exact ARM scope. + `Storage Blob Data Contributor`, both at that container's exact ARM scope. Before mutating an + existing deployment and again during final verification, it rejects broader viewer Blob-read + assignments discovered anywhere in the subscription or inherited from a parent scope. 3. Creates or reuses one uniquely named, secret-free, current-tenant `AzureADMyOrg` app registration and service principal. The registration accepts only the canonical App Service callback. @@ -124,6 +130,11 @@ Federated credentials and Blob role assignments can take several minutes to prop sign-in or Blob read that fails immediately after provisioning should be retried after propagation; do not replace the managed-identity federation with a client secret. +If the containment check reports an assignment ID, role, and scope, remove that assignment or +select a truly dedicated viewer identity. The script deliberately does not delete it because it +may authorize another workload. Conditions on broader assignments are not accepted as proof of +container-only access. + By default, every identity in the current workforce tenant can authenticate. If a smaller audience is required, enable enterprise-application assignment and assign the intended users or groups in Entra; keep the app registration single-tenant. diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index dd08808e..490520c0 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -197,6 +197,13 @@ remote-URL fetch that would otherwise be a working exfiltration channel. - The publisher keeps its existing delegated Entra/Azure CLI identity and `Storage Blob Data Contributor` grant. The viewer uses a managed identity with the new read-only grant scoped to the share container. +- When the requested viewer identity already exists, provisioning audits its Blob-read RBAC before + any Azure mutation and repeats the audit as part of deployed-state verification. The audit + enumerates direct and group-derived assignments throughout the subscription plus assignments + inherited from parent scopes, resolves each role definition's effective + `dataActions`/`notDataActions`, and fails on a Blob-read grant unless its assignment scope is the + configured share container or a descendant. It reports but never deletes an offending + assignment, because that grant may belong to another workload. - Provisioning ensures the configured share container exists with anonymous access disabled before either container-scoped grant is applied; the publisher intentionally does not create containers at share time. @@ -205,12 +212,12 @@ remote-URL fetch that would otherwise be a working exfiltration channel. registration; it reads account/container from the machine-level `canvasShare` config and resolves the delegated publisher as the current Azure CLI user. The fixed B1 Linux plan and any new resource group use the storage account's Azure location. -- `-ValidateOnly` performs subscription/tenant, configuration, existing-resource, global-name, and - local-publish checks without changing Azure or machine configuration. An apply run reconciles - resources, merges the canvas rule into the account's complete lifecycle policy without removing - unrelated rules, deploys with `az webapp deploy` after SCM/FTP basic authentication is disabled, - verifies the resulting control-plane state, and writes the exact canonical `viewerBaseUrl` while - preserving every other machine setting. +- `-ValidateOnly` performs subscription/tenant, configuration, existing-resource, viewer-identity + RBAC, global-name, and local-publish checks without changing Azure or machine configuration. An + apply run reconciles resources, merges the canvas rule into the account's complete lifecycle + policy without removing unrelated rules, deploys with `az webapp deploy` after SCM/FTP basic + authentication is disabled, verifies the resulting control-plane state, and writes the exact + canonical `viewerBaseUrl` while preserving every other machine setting. - Easy Auth's `clientSecretSettingName` is the `OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID` sentinel; the slot-sticky app setting with that name contains the user-assigned identity's client ID. The registration's federated credential trusts @@ -258,6 +265,7 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | Allow `unsafe-eval` only inside the contained document response | Shared canvases already support arbitrary inline JavaScript; preserving `eval`/`new Function` compatibility does not grant viewer-origin or network access because the sandbox and remaining CSP directives still deny both. | | Use `treemon.azurewebsites.net` rather than a custom domain or generated suffix | The Azure-provided hostname is short, TLS-enabled, requires no DNS ownership, and gives every shared document one stable origin for browser SSO. | | Derive storage and publisher deployment inputs from machine/Azure CLI state | The existing `canvasShare` account/container and current delegated publisher are already the publisher's source of truth. Requiring them again as script arguments would permit a viewer and publisher to be provisioned against different containers or identities. | +| Treat only a container-scoped RBAC assignment (or a descendant scope) as proof of viewer containment | Fully interpreting arbitrary Azure RBAC conditions would reproduce the authorization engine and could silently accept a broader grant. A conditioned assignment at an account, resource-group, subscription, or parent scope therefore fails closed; the operator must remove it or use a dedicated identity. | | Merge the lifecycle rule instead of replacing the account policy | Azure lifecycle policies are whole-document resources. Preserving unrelated rules avoids destructive drift when the storage account has other lifecycle-managed data. | | Share with the whole tenant instead of requiring enterprise-application assignment | Sharing is link-driven and ad hoc; a maintained assignment list would lock out the colleagues and guests a link is handed to, while the unguessable path, tenant sign-in, and expiry already bound exposure. | | Guarantee an identical not-found response but not identical timing | Status, headers, body, and emission order are what an authenticated recipient can compare reliably; equalizing elapsed time would need a threat model, a maximum blob size, and padding, which is disproportionate to the leaked fact that a path once existed. | @@ -277,6 +285,7 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | `src/Client/CanvasPane.fs`, `CanvasState.fs`, `CanvasUpdate.fs`, `index.html` | Share button, `ShareState` phase machine, clipboard write and banner routing (unchanged) | | `src/CanvasShareViewer/` | New App Service viewer: shell route, content route, expiry check, sandbox/CSP, Easy Auth configuration | | `scripts/deploy-canvas-share-viewer.ps1` | Idempotent non-production Azure provisioning, secret-free Easy Auth, Entra-authenticated ZIP deployment, validation, and machine-config update | +| `scripts/canvas-share-viewer-deployment/ViewerBlobAccess.ps1` | Fail-closed audit of the viewer identity's effective Blob-read data-plane assignments | | `scripts/canvas-share-lifecycle-policy.json` | Container-filtered deletion rule starting after 31 days | | `docs/canvas-share-viewer-deployment.md` | Local operator prerequisites, dry run, apply, and durable-resource guidance | @@ -288,8 +297,9 @@ remote-URL fetch that would otherwise be a working exfiltration channel. - Entra redirect/allow/deny: an anonymous request redirects to sign-in; any identity the tenant authenticates -- member or B2B guest -- views the document; an identity outside the tenant is denied. -- The viewer's managed identity can read the share container via Blob storage and nothing beyond - it. +- The control-plane RBAC audit finds no effective Blob data-plane read assignment outside the share + container, and the live second-container probe under the deployed viewer identity still returns + 403 as defense in depth. - A document is denied immediately once its metadata expiry has passed, before any lifecycle deletion runs. - The content route enforces the same checks as the shell: an expired or malformed share is denied diff --git a/scripts/canvas-share-viewer-deployment/Azure.ps1 b/scripts/canvas-share-viewer-deployment/Azure.ps1 index 42263d37..924cb6dd 100644 --- a/scripts/canvas-share-viewer-deployment/Azure.ps1 +++ b/scripts/canvas-share-viewer-deployment/Azure.ps1 @@ -228,6 +228,15 @@ function Ensure-ResourceGroup { '--subscription', $SubscriptionId) } +function Get-ShareContainerScope { + param( + [Parameter(Mandatory)][pscustomobject] $StorageAccount, + [Parameter(Mandatory)][string] $Container + ) + + "$($StorageAccount.id)/blobServices/default/containers/$Container" +} + function Ensure-PrivateContainer { param( [Parameter(Mandatory)][pscustomobject] $StorageAccount, @@ -264,7 +273,9 @@ function Ensure-PrivateContainer { '--subscription', $SubscriptionId) } - "$($StorageAccount.id)/blobServices/default/containers/$Container" + Get-ShareContainerScope ` + -StorageAccount $StorageAccount ` + -Container $Container } function Ensure-AppServicePlan { @@ -802,6 +813,11 @@ function Assert-DeployedState { ) Write-Step 'Verifying deployed control-plane configuration' + Assert-ViewerBlobAccessIsContainerOnly ` + -PrincipalObjectId ([string] $ManagedIdentity.principalId) ` + -ContainerScope $ContainerScope ` + -SubscriptionId $SubscriptionId + $webApp = Invoke-AzJson -Arguments @( 'webapp', 'show', '--name', $appName, diff --git a/scripts/canvas-share-viewer-deployment/Common.ps1 b/scripts/canvas-share-viewer-deployment/Common.ps1 index 12be27f8..8d396f8d 100644 --- a/scripts/canvas-share-viewer-deployment/Common.ps1 +++ b/scripts/canvas-share-viewer-deployment/Common.ps1 @@ -229,8 +229,8 @@ function Assert-Prerequisites { $azVersions = Invoke-AzJson -Arguments @('version') $azVersion = [version] $azVersions.'azure-cli' - if ($azVersion -lt [version] '2.48.1') { - throw "Azure CLI 2.48.1 or later is required for Microsoft Entra-authenticated App Service deployment. Found $azVersion." + if ($azVersion -lt [version] '2.72.0') { + throw "Azure CLI 2.72.0 or later is required for viewer RBAC inspection and Microsoft Entra-authenticated App Service deployment. Found $azVersion." } $dotnetVersionText = (& dotnet --version).Trim() diff --git a/scripts/canvas-share-viewer-deployment/ViewerBlobAccess.Tests.ps1 b/scripts/canvas-share-viewer-deployment/ViewerBlobAccess.Tests.ps1 new file mode 100644 index 00000000..a8bd6b03 --- /dev/null +++ b/scripts/canvas-share-viewer-deployment/ViewerBlobAccess.Tests.ps1 @@ -0,0 +1,302 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:directAssignments = @() +$script:inheritedAssignments = @() +$script:roleDefinitions = @{} +$script:allQueryCount = 0 +$script:inheritedQueryCount = 0 +$script:roleDefinitionQueryCount = 0 + +function Invoke-AzJson { + param([Parameter(Mandatory)][string[]] $Arguments) + + $command = $Arguments[0..2] -join ' ' + + switch ($command) { + 'role assignment list' { + if ($Arguments -notcontains '--include-groups') { + throw "Role-assignment query omitted --include-groups: $($Arguments -join ' ')" + } + + if ($Arguments -contains '--all') { + $script:allQueryCount++ + return $script:directAssignments + } + + if ($Arguments -contains '--include-inherited') { + $script:inheritedQueryCount++ + return $script:inheritedAssignments + } + + throw "Unexpected role-assignment query: $($Arguments -join ' ')" + } + 'role definition show' { + $script:roleDefinitionQueryCount++ + $idIndex = [Array]::IndexOf($Arguments, '--id') + + if ($idIndex -lt 0 -or $idIndex + 1 -ge $Arguments.Count) { + throw "Role-definition query omitted --id: $($Arguments -join ' ')" + } + + $roleDefinitionId = $Arguments[$idIndex + 1] + + if (-not $script:roleDefinitions.ContainsKey($roleDefinitionId)) { + throw "No mocked role definition for '$roleDefinitionId'." + } + + return $script:roleDefinitions[$roleDefinitionId] + } + default { + throw "Unexpected Azure CLI command: $($Arguments -join ' ')" + } + } +} + +. (Join-Path $PSScriptRoot 'ViewerBlobAccess.ps1') + +function Reset-Mocks { + $script:directAssignments = @() + $script:inheritedAssignments = @() + $script:roleDefinitions = @{} + $script:allQueryCount = 0 + $script:inheritedQueryCount = 0 + $script:roleDefinitionQueryCount = 0 +} + +function Assert-TextContains { + param( + [Parameter(Mandatory)][string] $Actual, + [Parameter(Mandatory)][string] $Expected + ) + + if (-not $Actual.Contains($Expected, [StringComparison]::Ordinal)) { + throw "Expected '$Actual' to contain '$Expected'." + } +} + +function Assert-Equal { + param( + [Parameter(Mandatory)][object] $Actual, + [Parameter(Mandatory)][object] $Expected, + [Parameter(Mandatory)][string] $Because + ) + + if ($Actual -ne $Expected) { + throw "Expected '$Expected' but got '$Actual': $Because" + } +} + +function Invoke-TestCase { + param( + [Parameter(Mandatory)][string] $Name, + [Parameter(Mandatory)][scriptblock] $Body + ) + + & $Body + Write-Host "PASS: $Name" +} + +$subscriptionId = '11111111-1111-1111-1111-111111111111' +$principalObjectId = '22222222-2222-2222-2222-222222222222' +$storageAccountScope = "/subscriptions/$subscriptionId/resourceGroups/storage-rg/providers/Microsoft.Storage/storageAccounts/shares" +$containerScope = "$storageAccountScope/blobServices/default/containers/canvas-shared" +$readerRoleDefinitionId = "/subscriptions/$subscriptionId/providers/Microsoft.Authorization/roleDefinitions/2a2b9908-6ea1-4ae2-8e65-a410df84e7d1" +$blobReadDataAction = 'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/read' + +Invoke-TestCase 'rejects an unconditioned account-scoped Storage Blob Data Reader assignment' { + Reset-Mocks + $assignmentId = "$storageAccountScope/providers/Microsoft.Authorization/roleAssignments/broad-reader" + $script:directAssignments = @( + [pscustomobject]@{ + id = $assignmentId + roleDefinitionId = $readerRoleDefinitionId + scope = $storageAccountScope + condition = $null + } + ) + $script:roleDefinitions[$readerRoleDefinitionId] = + [pscustomobject]@{ + id = $readerRoleDefinitionId + roleName = 'Storage Blob Data Reader' + permissions = @( + [pscustomobject]@{ + dataActions = @($blobReadDataAction) + notDataActions = @() + } + ) + } + + $message = '' + + try { + Assert-ViewerBlobAccessIsContainerOnly ` + -PrincipalObjectId $principalObjectId ` + -ContainerScope $containerScope ` + -SubscriptionId $subscriptionId + } catch { + $message = $_.Exception.Message + } + + if ([string]::IsNullOrWhiteSpace($message)) { + throw 'Expected the broader Blob reader assignment to be rejected.' + } + + Assert-TextContains -Actual $message -Expected $assignmentId + Assert-TextContains -Actual $message -Expected 'Storage Blob Data Reader' + Assert-TextContains -Actual $message -Expected $storageAccountScope + Assert-TextContains -Actual $message -Expected 'will not delete this assignment' +} + +Invoke-TestCase 'allows and de-duplicates a role confined to the share container' { + Reset-Mocks + $assignment = + [pscustomobject]@{ + id = "$containerScope/providers/Microsoft.Authorization/roleAssignments/container-reader" + roleDefinitionId = $readerRoleDefinitionId + scope = $containerScope + condition = $null + } + $script:directAssignments = @($assignment) + $script:inheritedAssignments = @($assignment) + $script:roleDefinitions[$readerRoleDefinitionId] = + [pscustomobject]@{ + id = $readerRoleDefinitionId + roleName = 'Storage Blob Data Reader' + permissions = @( + [pscustomobject]@{ + dataActions = @($blobReadDataAction) + notDataActions = @() + } + ) + } + + Assert-ViewerBlobAccessIsContainerOnly ` + -PrincipalObjectId $principalObjectId ` + -ContainerScope $containerScope ` + -SubscriptionId $subscriptionId + + Assert-Equal -Actual $script:allQueryCount -Expected 1 -Because 'direct assignments must be enumerated across the subscription' + Assert-Equal -Actual $script:inheritedQueryCount -Expected 1 -Because 'assignments inherited from parent scopes must be enumerated' + Assert-Equal -Actual $script:roleDefinitionQueryCount -Expected 1 -Because 'duplicate assignments should resolve their role once' +} + +Invoke-TestCase 'rejects a Blob reader assignment returned only by the inherited-scope query' { + Reset-Mocks + $managementGroupScope = '/providers/Microsoft.Management/managementGroups/development' + $assignmentId = "$managementGroupScope/providers/Microsoft.Authorization/roleAssignments/inherited-reader" + $script:inheritedAssignments = @( + [pscustomobject]@{ + id = $assignmentId + roleDefinitionId = $readerRoleDefinitionId + scope = $managementGroupScope + condition = $null + } + ) + $script:roleDefinitions[$readerRoleDefinitionId] = + [pscustomobject]@{ + id = $readerRoleDefinitionId + roleName = 'Storage Blob Data Reader' + permissions = @( + [pscustomobject]@{ + dataActions = @($blobReadDataAction) + notDataActions = @() + } + ) + } + + $message = '' + + try { + Assert-ViewerBlobAccessIsContainerOnly ` + -PrincipalObjectId $principalObjectId ` + -ContainerScope $containerScope ` + -SubscriptionId $subscriptionId + } catch { + $message = $_.Exception.Message + } + + if ([string]::IsNullOrWhiteSpace($message)) { + throw 'Expected the inherited Blob reader assignment to be rejected.' + } + + Assert-TextContains -Actual $message -Expected $assignmentId + Assert-TextContains -Actual $message -Expected $managementGroupScope +} + +Invoke-TestCase 'fails closed on a conditioned Blob reader assignment at a broader scope' { + Reset-Mocks + $assignmentId = "$storageAccountScope/providers/Microsoft.Authorization/roleAssignments/conditioned-reader" + $script:directAssignments = @( + [pscustomobject]@{ + id = $assignmentId + roleDefinitionId = $readerRoleDefinitionId + scope = $storageAccountScope + condition = "@Resource[Microsoft.Storage/storageAccounts/blobServices/containers:name] StringEquals 'canvas-shared'" + } + ) + $script:roleDefinitions[$readerRoleDefinitionId] = + [pscustomobject]@{ + id = $readerRoleDefinitionId + roleName = 'Storage Blob Data Reader' + permissions = @( + [pscustomobject]@{ + dataActions = @($blobReadDataAction) + notDataActions = @() + } + ) + } + + $message = '' + + try { + Assert-ViewerBlobAccessIsContainerOnly ` + -PrincipalObjectId $principalObjectId ` + -ContainerScope $containerScope ` + -SubscriptionId $subscriptionId + } catch { + $message = $_.Exception.Message + } + + if ([string]::IsNullOrWhiteSpace($message)) { + throw 'Expected the conditioned broader Blob reader assignment to be rejected.' + } + + Assert-TextContains -Actual $message -Expected $assignmentId + Assert-TextContains ` + -Actual $message ` + -Expected 'A condition on a broader assignment is not accepted as proof of container-only access.' +} + +Invoke-TestCase 'honors notDataActions when calculating effective Blob read' { + Reset-Mocks + $customRoleDefinitionId = "/subscriptions/$subscriptionId/providers/Microsoft.Authorization/roleDefinitions/33333333-3333-3333-3333-333333333333" + $script:directAssignments = @( + [pscustomobject]@{ + id = "$storageAccountScope/providers/Microsoft.Authorization/roleAssignments/excluded-reader" + roleDefinitionId = $customRoleDefinitionId + scope = $storageAccountScope + condition = $null + } + ) + $script:roleDefinitions[$customRoleDefinitionId] = + [pscustomobject]@{ + id = $customRoleDefinitionId + roleName = 'Storage data except Blob read' + permissions = @( + [pscustomobject]@{ + dataActions = @('Microsoft.Storage/*') + notDataActions = @($blobReadDataAction) + } + ) + } + + Assert-ViewerBlobAccessIsContainerOnly ` + -PrincipalObjectId $principalObjectId ` + -ContainerScope $containerScope ` + -SubscriptionId $subscriptionId +} + +Write-Host 'Viewer Blob access regression tests passed.' diff --git a/scripts/canvas-share-viewer-deployment/ViewerBlobAccess.ps1 b/scripts/canvas-share-viewer-deployment/ViewerBlobAccess.ps1 new file mode 100644 index 00000000..70a64342 --- /dev/null +++ b/scripts/canvas-share-viewer-deployment/ViewerBlobAccess.ps1 @@ -0,0 +1,226 @@ +function Get-RolePermissionValues { + param( + [Parameter(Mandatory)][pscustomobject] $Permission, + [Parameter(Mandatory)][ValidateSet('dataActions', 'notDataActions')][string] $PropertyName + ) + + $property = $Permission.PSObject.Properties[$PropertyName] + + if ($null -eq $property -or $null -eq $property.Value) { + return @() + } + + @($property.Value) +} + +function Test-DataActionPatternMatches { + param( + [Parameter(Mandatory)][string] $Pattern, + [Parameter(Mandatory)][string] $DataAction + ) + + $expression = '^' + [Regex]::Escape($Pattern).Replace('\*', '.*') + '$' + [Regex]::IsMatch( + $DataAction, + $expression, + [Text.RegularExpressions.RegexOptions]::IgnoreCase -bor + [Text.RegularExpressions.RegexOptions]::CultureInvariant) +} + +function Test-RoleDefinitionGrantsBlobRead { + param([Parameter(Mandatory)][pscustomobject] $RoleDefinition) + + $permissionsProperty = $RoleDefinition.PSObject.Properties['permissions'] + + if ($null -eq $permissionsProperty -or $null -eq $permissionsProperty.Value) { + throw "Role definition '$($RoleDefinition.id)' did not expose permissions. Viewer Blob access cannot be proven container-only." + } + + $blobReadDataAction = 'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/read' + + foreach ($permission in @($permissionsProperty.Value)) { + $grantsBlobRead = @( + Get-RolePermissionValues -Permission $permission -PropertyName dataActions + | Where-Object { + Test-DataActionPatternMatches ` + -Pattern ([string] $_) ` + -DataAction $blobReadDataAction + } + ).Count -gt 0 + + $excludesBlobRead = @( + Get-RolePermissionValues -Permission $permission -PropertyName notDataActions + | Where-Object { + Test-DataActionPatternMatches ` + -Pattern ([string] $_) ` + -DataAction $blobReadDataAction + } + ).Count -gt 0 + + if ($grantsBlobRead -and -not $excludesBlobRead) { + return $true + } + } + + $false +} + +function Test-ScopeIsWithinShareContainer { + param( + [Parameter(Mandatory)][string] $Scope, + [Parameter(Mandatory)][string] $ContainerScope + ) + + $normalizedScope = $Scope.TrimEnd('/') + $normalizedContainerScope = $ContainerScope.TrimEnd('/') + + [string]::Equals( + $normalizedScope, + $normalizedContainerScope, + [StringComparison]::OrdinalIgnoreCase) -or + $normalizedScope.StartsWith( + "$normalizedContainerScope/", + [StringComparison]::OrdinalIgnoreCase) +} + +function Get-ViewerRoleAssignments { + param( + [Parameter(Mandatory)][string] $PrincipalObjectId, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + $directAssignments = @( + Invoke-AzJson -Arguments @( + 'role', 'assignment', 'list', + '--assignee-object-id', $PrincipalObjectId, + '--all', + '--include-groups', + '--fill-principal-name', 'false', + '--fill-role-definition-name', 'false', + '--subscription', $SubscriptionId) + ) + $inheritedAssignments = @( + Invoke-AzJson -Arguments @( + 'role', 'assignment', 'list', + '--assignee-object-id', $PrincipalObjectId, + '--scope', "/subscriptions/$SubscriptionId", + '--include-inherited', + '--include-groups', + '--fill-principal-name', 'false', + '--fill-role-definition-name', 'false', + '--subscription', $SubscriptionId) + ) + $assignmentsById = + [Collections.Generic.Dictionary[string, object]]::new( + [StringComparer]::OrdinalIgnoreCase) + + foreach ($assignment in @($directAssignments) + @($inheritedAssignments)) { + if ($null -eq $assignment) { + throw 'Azure returned an empty viewer role assignment. Viewer Blob access cannot be proven container-only.' + } + + $idProperty = $assignment.PSObject.Properties['id'] + $assignmentId = + if ($null -eq $idProperty) { + '' + } else { + [string] $idProperty.Value + } + + if ([string]::IsNullOrWhiteSpace($assignmentId)) { + throw 'Azure returned a viewer role assignment without an ID. Viewer Blob access cannot be proven container-only.' + } + + $assignmentsById[$assignmentId] = $assignment + } + + @($assignmentsById.Values) +} + +function Get-RequiredRoleAssignmentValue { + param( + [Parameter(Mandatory)][pscustomobject] $Assignment, + [Parameter(Mandatory)][ValidateSet('id', 'roleDefinitionId', 'scope')][string] $PropertyName + ) + + $property = $Assignment.PSObject.Properties[$PropertyName] + $value = + if ($null -eq $property) { + '' + } else { + [string] $property.Value + } + + if ([string]::IsNullOrWhiteSpace($value)) { + throw "Azure returned a viewer role assignment without '$PropertyName'. Viewer Blob access cannot be proven container-only." + } + + $value +} + +function Assert-ViewerBlobAccessIsContainerOnly { + param( + [Parameter(Mandatory)][string] $PrincipalObjectId, + [Parameter(Mandatory)][string] $ContainerScope, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + $assignments = @( + Get-ViewerRoleAssignments ` + -PrincipalObjectId $PrincipalObjectId ` + -SubscriptionId $SubscriptionId + ) + $roleDefinitions = + [Collections.Generic.Dictionary[string, object]]::new( + [StringComparer]::OrdinalIgnoreCase) + + foreach ($assignment in $assignments) { + $assignmentId = Get-RequiredRoleAssignmentValue -Assignment $assignment -PropertyName id + $roleDefinitionId = + Get-RequiredRoleAssignmentValue -Assignment $assignment -PropertyName roleDefinitionId + $scope = Get-RequiredRoleAssignmentValue -Assignment $assignment -PropertyName scope + + if (-not $roleDefinitions.ContainsKey($roleDefinitionId)) { + try { + $roleDefinition = Invoke-AzJson -Arguments @( + 'role', 'definition', 'show', + '--id', $roleDefinitionId, + '--subscription', $SubscriptionId) + } catch { + throw "Could not resolve role definition '$roleDefinitionId' for viewer assignment '$assignmentId' at scope '$scope'. Viewer Blob access cannot be proven container-only. $($_.Exception.Message)" + } + + if ($null -eq $roleDefinition) { + throw "Role definition '$roleDefinitionId' for viewer assignment '$assignmentId' at scope '$scope' was not found. Viewer Blob access cannot be proven container-only." + } + + $roleDefinitions[$roleDefinitionId] = $roleDefinition + } + + $resolvedRoleDefinition = $roleDefinitions[$roleDefinitionId] + + if ((Test-RoleDefinitionGrantsBlobRead -RoleDefinition $resolvedRoleDefinition) -and + -not (Test-ScopeIsWithinShareContainer ` + -Scope $scope ` + -ContainerScope $ContainerScope)) { + $roleNameProperty = $resolvedRoleDefinition.PSObject.Properties['roleName'] + $roleName = + if ($null -eq $roleNameProperty -or + [string]::IsNullOrWhiteSpace([string] $roleNameProperty.Value)) { + $roleDefinitionId + } else { + [string] $roleNameProperty.Value + } + $conditionProperty = $assignment.PSObject.Properties['condition'] + $conditionNote = + if ($null -ne $conditionProperty -and + -not [string]::IsNullOrWhiteSpace([string] $conditionProperty.Value)) { + ' A condition on a broader assignment is not accepted as proof of container-only access.' + } else { + '' + } + + throw "Viewer Blob-read access is not confined to '$ContainerScope'. Offending assignment: id='$assignmentId'; role='$roleName' ($roleDefinitionId); scope='$scope'.$conditionNote The deployment will not delete this assignment because it may belong to another workload. Remove it or use a truly dedicated viewer identity." + } + } +} diff --git a/scripts/deploy-canvas-share-viewer.ps1 b/scripts/deploy-canvas-share-viewer.ps1 index ebc671ab..dcb70340 100644 --- a/scripts/deploy-canvas-share-viewer.ps1 +++ b/scripts/deploy-canvas-share-viewer.ps1 @@ -46,6 +46,7 @@ $lifecyclePolicyPath = Join-Path $PSScriptRoot 'canvas-share-lifecycle-policy.js $deploymentSupportDirectory = Join-Path $PSScriptRoot 'canvas-share-viewer-deployment' . (Join-Path $deploymentSupportDirectory 'Common.ps1') +. (Join-Path $deploymentSupportDirectory 'ViewerBlobAccess.ps1') . (Join-Path $deploymentSupportDirectory 'Azure.ps1') Write-Step 'Validating local tools and repository inputs' @@ -58,11 +59,22 @@ $azureContext = Get-AzureContext $storageAccount = Get-StorageAccount ` -AccountName $treemonConfig.AccountName ` -SubscriptionId $azureContext.SubscriptionId +$containerScope = Get-ShareContainerScope ` + -StorageAccount $storageAccount ` + -Container $treemonConfig.Container $existingResources = Get-ExistingResources -SubscriptionId $azureContext.SubscriptionId Assert-ExistingResourceSafety ` -Existing $existingResources ` -SubscriptionId $azureContext.SubscriptionId +if ($null -ne $existingResources.Identity) { + Write-Step 'Verifying the existing viewer identity has container-only Blob access' + Assert-ViewerBlobAccessIsContainerOnly ` + -PrincipalObjectId ([string] $existingResources.Identity.principalId) ` + -ContainerScope $containerScope ` + -SubscriptionId $azureContext.SubscriptionId +} + $workingDirectory = Join-Path ([IO.Path]::GetTempPath()) "treemon-viewer-$PID-$([Guid]::NewGuid().ToString('N'))" New-Item -ItemType Directory -Path $workingDirectory | Out-Null From 0ba4072c5dda8103a4c7cc0591645d37b91df539 Mon Sep 17 00:00:00 2001 From: Petr Pokorny <petr@innit.cz> Date: Tue, 18 Aug 2026 10:40:00 +0200 Subject: [PATCH 08/27] tm-canvas-safe-share-c58 Handle viewer dependency failures safely Limit the viewer's 404-to-not-found path to a genuine BlobNotFound and add an exception boundary before routing that turns non-404 storage and DefaultAzureCredential failures into one fixed, empty 503 with restrictive policy headers in every environment, logging only exception type and Azure status/error code. Pin and assert DOTNET_ENVIRONMENT and ASPNETCORE_ENVIRONMENT to Production in the deployment script, and record the behaviour in the spec and deployment doc. --- docs/canvas-share-viewer-deployment.md | 8 +- docs/spec/canvas-sharing.md | 18 ++ .../canvas-share-viewer-deployment/Azure.ps1 | 4 + src/CanvasShareViewer/BlobStorage.fs | 13 +- src/CanvasShareViewer/ViewerApplication.fs | 82 ++++++ src/Tests/CanvasShareViewerTests.fs | 249 ++++++++++++++++++ 6 files changed, 370 insertions(+), 4 deletions(-) diff --git a/docs/canvas-share-viewer-deployment.md b/docs/canvas-share-viewer-deployment.md index 93390f69..3a4fa8bb 100644 --- a/docs/canvas-share-viewer-deployment.md +++ b/docs/canvas-share-viewer-deployment.md @@ -97,7 +97,8 @@ The script is idempotent and is intended to be run a second time with the same v slot-sticky `OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID` setting and its `clientSecretSettingName` sentinel, so no client secret is created. 5. Requires Easy Auth before requests reach the viewer, uses the tenant's v2 issuer, requests no - extra login scopes, requires HTTPS, and disables the token store. + extra login scopes, requires HTTPS, disables the token store, and pins both the .NET host and + ASP.NET Core environments to `Production`. 6. Merges `expire-shared-canvas-docs` into the storage account's complete lifecycle policy while preserving unrelated rules. Deletion starts only after more than 31 days, beyond the 30-day maximum share lifetime. @@ -127,8 +128,9 @@ access tokens, create deployment credentials, or read a publishing profile. ## After deployment Federated credentials and Blob role assignments can take several minutes to propagate. A first -sign-in or Blob read that fails immediately after provisioning should be retried after propagation; -do not replace the managed-identity federation with a client secret. +sign-in or Blob read that fails immediately after provisioning may return the viewer's empty 503 +response and should be retried after propagation; do not replace the managed-identity federation +with a client secret. If the containment check reports an assignment ID, role, and scope, remove that assignment or select a truly dedicated viewer identity. The script deliberately does not delete it because it diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index 490520c0..03195327 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -63,6 +63,10 @@ - Missing, malformed, and expired share paths return the same generic not-found response. Easy Auth handles unauthenticated identities before application code runs and never reveals whether the requested share exists. +- A `BlobNotFound` 404 is the only dependency outcome treated as a missing share. Container or + account failures, storage throttling, authorization, service, and managed-credential failures + return one fixed, empty 503 with restrictive response headers in every runtime environment, so + an outage is retryable without exposing framework diagnostics. - The audience is tenant-wide: any identity the tenant issuer authenticates -- a current-tenant user or an invited B2B guest -- may view a share whose link it holds. Enterprise-application assignment is deliberately not required, so possession of the link plus a tenant sign-in is the @@ -141,6 +145,10 @@ resolve to not-found before any storage access, so untrusted dot segments never reach Blob URI construction. Every not-found path then emits the identical response through the same application-level ordering; only elapsed time may differ. +- An exception boundary registered before routing handles non-404 Azure Storage failures and + `DefaultAzureCredential` failures independently of the ASP.NET Core environment. It logs only + the exception type and available Azure status/error code, clears the route response, and emits + the fixed dependency-failure response; it never converts an outage to not-found. - The shell's iframe uses `sandbox="allow-scripts"` only -- it omits `allow-same-origin`, `allow-forms`, `allow-popups`, and `allow-top-navigation`, so the embedded document's script can run but cannot read the viewer's cookies or storage, submit forms, open popups, or navigate the @@ -178,6 +186,7 @@ Response headers, by route: |---|---| | Shell | `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; form-action 'none'; base-uri 'none'`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store` | | Content | `Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; sandbox allow-scripts`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store` | +| Dependency failure (either route) | HTTP 503 with an empty body and `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store` | `script-src`/`style-src` allow inline because a self-contained canvas doc *is* inline script and style. `unsafe-eval` preserves existing support for documents that use `eval` or `new Function`; @@ -218,6 +227,9 @@ remote-URL fetch that would otherwise be a working exfiltration channel. policy without removing unrelated rules, deploys with `az webapp deploy` after SCM/FTP basic authentication is disabled, verifies the resulting control-plane state, and writes the exact canonical `viewerBaseUrl` while preserving every other machine setting. +- Deployment sets and deployed-state validation asserts both `DOTNET_ENVIRONMENT=Production` and + `ASPNETCORE_ENVIRONMENT=Production`; the application-level dependency exception boundary remains + active even if either platform setting later drifts. - Easy Auth's `clientSecretSettingName` is the `OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID` sentinel; the slot-sticky app setting with that name contains the user-assigned identity's client ID. The registration's federated credential trusts @@ -245,6 +257,9 @@ remote-URL fetch that would otherwise be a working exfiltration channel. headers, and body -- to an authenticated caller; only response latency may differ, which is an accepted residual signal rather than a guarantee. Easy Auth rejects identities the tenant does not authenticate without revealing path existence. +- Storage and credential outages are intentionally distinguishable from a missing share by their + fixed empty 503, but are not distinguishable by route or runtime environment and expose no + exception message, stack, request path, or document content. - The Remoting CSRF guard continues to protect the publish call itself: a forged cross-origin `shareCanvasDoc` request from the operator's browser is rejected before any Azure I/O, the same as every other `IWorktreeApi` state-changing endpoint. @@ -269,6 +284,7 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | Merge the lifecycle rule instead of replacing the account policy | Azure lifecycle policies are whole-document resources. Preserving unrelated rules avoids destructive drift when the storage account has other lifecycle-managed data. | | Share with the whole tenant instead of requiring enterprise-application assignment | Sharing is link-driven and ad hoc; a maintained assignment list would lock out the colleagues and guests a link is handed to, while the unguessable path, tenant sign-in, and expiry already bound exposure. | | Guarantee an identical not-found response but not identical timing | Status, headers, body, and emission order are what an authenticated recipient can compare reliably; equalizing elapsed time would need a threat model, a maximum blob size, and padding, which is disproportionate to the leaked fact that a path once existed. | +| Return a fixed 503 rather than 404 for storage or credential failures | Dependency outages are operational and retryable, not evidence that a share is missing; preserving that distinction avoids hiding failures while an environment-independent boundary prevents diagnostic disclosure. | | Let the deployment script write `~/.treemon/config.json` directly instead of through the running server | Provisioning must work with no Treemon instance running, and a server RPC or shared cross-process lock would add permanent coupling for a rare operator step; the script re-reads, replaces atomically, and preserves every other setting, and the operator runs it while Treemon is not writing config. | | Keep the publisher/viewer wire contract as pinned constants on both sides | The protocol is a handful of literals across two independently deployed apps, where a shared module would add coupling without preventing version skew; fixed-fixture compatibility tests catch drift at build time instead. | @@ -304,6 +320,8 @@ remote-URL fetch that would otherwise be a working exfiltration channel. deletion runs. - The content route enforces the same checks as the shell: an expired or malformed share is denied on the content route too, and a document opened directly at the content URL is still sandboxed. +- Throwing storage and credential readers produce the same empty policy-headered 503 on shell and + content routes in both Production and Development, with no framework diagnostic response. - Deleting or clearing a document's backing blob denies its link immediately (revocation). - A hostile fixture attempting cookie/storage access, same-origin fetches, form submission, popups, top navigation, and network exfiltration all fail inside the sandboxed iframe and CSP, diff --git a/scripts/canvas-share-viewer-deployment/Azure.ps1 b/scripts/canvas-share-viewer-deployment/Azure.ps1 index 924cb6dd..bf9cf264 100644 --- a/scripts/canvas-share-viewer-deployment/Azure.ps1 +++ b/scripts/canvas-share-viewer-deployment/Azure.ps1 @@ -697,6 +697,8 @@ function Ensure-AppSettings { '--settings', "CanvasShareViewer__StorageAccountName=$StorageAccountName", "CanvasShareViewer__ShareContainer=$Container", + 'DOTNET_ENVIRONMENT=Production', + 'ASPNETCORE_ENVIRONMENT=Production', "AZURE_CLIENT_ID=$($ManagedIdentity.clientId)", "WEBSITE_AUTH_AAD_ALLOWED_TENANTS=$TenantId", '--subscription', $SubscriptionId) @@ -877,6 +879,8 @@ function Assert-DeployedState { $expectedSettings = [ordered]@{ CanvasShareViewer__StorageAccountName = [string] $StorageAccount.name CanvasShareViewer__ShareContainer = $Container + DOTNET_ENVIRONMENT = 'Production' + ASPNETCORE_ENVIRONMENT = 'Production' AZURE_CLIENT_ID = [string] $ManagedIdentity.clientId WEBSITE_AUTH_AAD_ALLOWED_TENANTS = $TenantId $managedIdentityAssertionSetting = [string] $ManagedIdentity.clientId diff --git a/src/CanvasShareViewer/BlobStorage.fs b/src/CanvasShareViewer/BlobStorage.fs index fdfe655c..f2f9fb49 100644 --- a/src/CanvasShareViewer/BlobStorage.fs +++ b/src/CanvasShareViewer/BlobStorage.fs @@ -7,6 +7,7 @@ open System.Threading.Tasks open Azure open Azure.Core open Azure.Storage.Blobs +open Azure.Storage.Blobs.Models type internal BlobDocument = { Content: ReadOnlyMemory<byte> @@ -20,6 +21,16 @@ type internal BlobReader = module internal BlobStorage = + let internal isMissingBlobFailure + (failure: RequestFailedException) + = + failure.Status = 404 + && String.Equals( + failure.ErrorCode, + BlobErrorCode.BlobNotFound.ToString(), + StringComparison.Ordinal + ) + let private metadataMap (values: IDictionary<string, string>) = @@ -61,7 +72,7 @@ module internal BlobStorage = |> metadataMap } with | :? RequestFailedException as ex when - ex.Status = 404 + isMissingBlobFailure ex -> return None } } diff --git a/src/CanvasShareViewer/ViewerApplication.fs b/src/CanvasShareViewer/ViewerApplication.fs index 9a9c2821..4f8e0d11 100644 --- a/src/CanvasShareViewer/ViewerApplication.fs +++ b/src/CanvasShareViewer/ViewerApplication.fs @@ -4,13 +4,21 @@ open System open System.Text open System.Text.Encodings.Web open System.Threading.Tasks +open Azure +open Azure.Identity open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Hosting open Microsoft.AspNetCore.Http open Microsoft.Extensions.DependencyInjection +open Microsoft.Extensions.Logging module internal ViewerApplication = + type private DependencyFailureDetails = + { ExceptionType: string + AzureStatus: string + AzureErrorCode: string } + [<Literal>] let ShellRoute = "/c/{prefix}/{filename}" @@ -25,6 +33,10 @@ module internal ViewerApplication = let private ContentContentSecurityPolicy = "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; sandbox allow-scripts" + [<Literal>] + let private DependencyFailureContentSecurityPolicy = + "default-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'" + let private routeSegment name (context: HttpContext) = context.Request.RouteValues[name] |> Option.ofObj @@ -44,6 +56,73 @@ module internal ViewerApplication = context.Response.Headers["Cache-Control"] <- "no-store" + let rec private tryAzureFailureDetails + (error: exn) + = + match error with + | :? RequestFailedException as failure -> + Some( + failure.Status, + failure.ErrorCode |> Option.ofObj + ) + | _ -> + error.InnerException + |> Option.ofObj + |> Option.bind tryAzureFailureDetails + + let private (|DependencyFailure|_|) + (error: exn) + = + match error with + | :? RequestFailedException + | :? AuthenticationFailedException + | :? CredentialUnavailableException -> + let status, errorCode = + error + |> tryAzureFailureDetails + |> Option.map (fun (status, errorCode) -> + Some(string status), errorCode) + |> Option.defaultValue (None, None) + + Some + { ExceptionType = error.GetType().Name + AzureStatus = + status + |> Option.defaultValue "unavailable" + AzureErrorCode = + errorCode + |> Option.defaultValue "unavailable" } + | _ -> + None + + let private handleDependencyFailures + (logger: ILogger) + (context: HttpContext) + (next: RequestDelegate) + : Task = + task { + try + do! next.Invoke(context) + with + | DependencyFailure failure -> + logger.LogError( + "Viewer dependency failure: ExceptionType={ExceptionType}; AzureStatus={AzureStatus}; AzureErrorCode={AzureErrorCode}", + [| + box failure.ExceptionType + box failure.AzureStatus + box failure.AzureErrorCode + |] + ) + + context.Response.Clear() + context.Response.StatusCode <- + StatusCodes.Status503ServiceUnavailable + applyResponsePolicy + DependencyFailureContentSecurityPolicy + context + context.Response.ContentLength <- 0L + } + let private shellBytes (context: HttpContext) = let prefix = routeSegment "prefix" context @@ -134,6 +213,9 @@ module internal ViewerApplication = builder.Services.AddRouting() |> ignore let app = builder.Build() + app.Use(fun context next -> + handleDependencyFailures app.Logger context next) + |> ignore app.UseRouting() |> ignore app.MapGet( diff --git a/src/Tests/CanvasShareViewerTests.fs b/src/Tests/CanvasShareViewerTests.fs index 18c13eff..b54fa068 100644 --- a/src/Tests/CanvasShareViewerTests.fs +++ b/src/Tests/CanvasShareViewerTests.fs @@ -1,6 +1,7 @@ module Tests.CanvasShareViewerTests open System +open System.Collections.Concurrent open System.Collections.Generic open System.Globalization open System.IO @@ -11,11 +12,14 @@ open System.Text open System.Threading open System.Threading.Tasks open System.Xml.Linq +open Azure +open Azure.Identity open CanvasShareViewer open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Hosting open Microsoft.AspNetCore.Routing open Microsoft.Extensions.Configuration +open Microsoft.Extensions.Logging open NUnit.Framework open Tests.TestUtils @@ -27,6 +31,9 @@ let private shellContentSecurityPolicy = let private contentContentSecurityPolicy = "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; sandbox allow-scripts" +let private dependencyFailureContentSecurityPolicy = + "default-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'" + let private formatExpiry (value: DateTimeOffset) = value.ToString("o", CultureInfo.InvariantCulture) @@ -44,6 +51,53 @@ type private FakeBlobReader = { Reader: BlobReader Requests: unit -> string list } +type private CapturedLog = + { Level: LogLevel + Message: string + Exception: exn option } + +let private nullLogScope = + { new IDisposable with + member _.Dispose() = () } + +type private CapturingLogger + (logs: ConcurrentQueue<CapturedLog>) + = + interface ILogger with + member _.BeginScope<'TState> + (_state: 'TState) + = + nullLogScope + + member _.IsEnabled(_level) = + true + + member _.Log<'TState>( + level, + _eventId, + state: 'TState, + error, + formatter + ) = + logs.Enqueue( + { Level = level + Message = formatter.Invoke(state, error) + Exception = error |> Option.ofObj } + ) + +type private CapturingLoggerProvider() = + // The concurrent queue is confined to this test logger's framework callback boundary. + let logs = ConcurrentQueue<CapturedLog>() + + member _.Entries() = + logs.ToArray() |> List.ofArray + + interface ILoggerProvider with + member _.CreateLogger(_categoryName) = + CapturingLogger(logs) + + member _.Dispose() = () + let private fakeBlobReader documents = // Mutation is confined to this fake's request observer; production storage state is immutable. let mutable requestsRev = [] @@ -90,6 +144,53 @@ let private withViewer .GetAwaiter() .GetResult() +let private withThrowingViewer + environmentName + (createError: unit -> exn) + now + (action: + CapturingLoggerProvider -> + HttpClient -> + string -> + unit) + = + let port = getFreeTcpPort () + let options = + WebApplicationOptions( + EnvironmentName = environmentName + ) + let builder = WebApplication.CreateBuilder(options) + builder.Logging.ClearProviders() |> ignore + let logs = new CapturingLoggerProvider() + builder.Logging.AddProvider(logs) |> ignore + builder.WebHost.UseKestrel(fun options -> + options.Listen(IPAddress.Loopback, port)) + |> ignore + + let reader = + { ReadExact = + fun _ _ -> + createError () + |> Task.FromException<BlobDocument option> } + + use app = + ViewerApplication.create + builder + reader + (fun () -> now) + + app.StartAsync(CancellationToken.None) + .GetAwaiter() + .GetResult() + + try + use client = new HttpClient() + action logs client $"http://127.0.0.1:{port}" + finally + app.StopAsync(CancellationToken.None) + .GetAwaiter() + .GetResult() + let private await (work: Task<'value>) = work.GetAwaiter().GetResult() @@ -124,6 +225,12 @@ let private expectedPolicyHeaders contentSecurityPolicy = "X-Content-Type-Options", [ "nosniff" ] ] +let private expectedDependencyFailureHeaders = + ("Content-Length", [ "0" ]) + :: expectedPolicyHeaders + dependencyFailureContentSecurityPolicy + |> List.sortBy fst + let private parseHtmlDom (html: string) = html.Replace( "<!doctype html>", @@ -185,6 +292,32 @@ let private configuration values = .AddInMemoryCollection |> _.Build() +[<TestFixture>] +[<Category("Unit")>] +[<Category("Fast")>] +type BlobStorageFailureTests() = + + [<TestCase(404, "BlobNotFound", true)>] + [<TestCase(404, "ContainerNotFound", false)>] + [<TestCase(403, "AuthorizationPermissionMismatch", false)>] + member _.``only a missing-blob response becomes not-found``( + status: int, + errorCode: string, + expected: bool + ) = + let failure = + RequestFailedException( + status, + "sensitive Azure diagnostics", + errorCode, + null + ) + + Assert.That( + BlobStorage.isMissingBlobFailure failure, + Is.EqualTo(expected) + ) + [<TestFixture>] [<Category("Unit")>] [<Category("Fast")>] @@ -443,6 +576,77 @@ type ViewerRouteTests() = now.AddHours(1.0) |> formatExpiry ] + let assertDependencyFailure + environmentName + createError + expectedLogMessage + = + withThrowingViewer + environmentName + createError + now + (fun logs client baseUrl -> + let snapshots = + [ + $"{baseUrl}/c/{validPrefix}/report.html" + $"{baseUrl}/c/{validPrefix}/report.html/content" + ] + |> List.map (responseSnapshot client) + + let expected = snapshots |> List.head + let errorLogs = + logs.Entries() + |> List.filter (fun entry -> + entry.Level = LogLevel.Error) + + Assert.Multiple(fun () -> + snapshots + |> List.iter (fun snapshot -> + Assert.That( + snapshot.StatusCode, + Is.EqualTo( + HttpStatusCode.ServiceUnavailable + ) + ) + + Assert.That( + snapshot.Headers, + Is.EqualTo( + expectedDependencyFailureHeaders + ) + ) + + Assert.That( + snapshot.Body, + Is.Empty, + "dependency failures must not expose diagnostics" + ) + + Assert.That( + snapshot, + Is.EqualTo(expected), + "shell and content routes must emit one fixed dependency-failure response" + )) + + Assert.That( + errorLogs |> List.length, + Is.EqualTo(2), + "each failed route must emit one safe dependency log" + ) + + errorLogs + |> List.iter (fun entry -> + Assert.That( + entry.Message, + Is.EqualTo(expectedLogMessage) + ) + + Assert.That( + entry.Exception, + Is.EqualTo(None: exn option), + "the logger must not receive the exception object or its diagnostics" + )))) + [<Test>] member _.``viewer maps exactly the two GET routes``() = let fake = fakeBlobReader Map.empty @@ -649,6 +853,51 @@ type ViewerRouteTests() = ) ))) + [<TestCase("Production")>] + [<TestCase("Development")>] + member _.``storage failures return a fixed empty 503 in every environment``(environmentName: string) = + assertDependencyFailure + environmentName + (fun () -> + RequestFailedException( + 429, + "sensitive storage diagnostics", + "ServerBusy", + InvalidOperationException( + "sensitive inner diagnostics" + ) + )) + "Viewer dependency failure: ExceptionType=RequestFailedException; AzureStatus=429; AzureErrorCode=ServerBusy" + + [<TestCase("Production")>] + [<TestCase("Development")>] + member _.``credential failures return a fixed empty 503 in every environment``(environmentName: string) = + [ + (fun () -> + AuthenticationFailedException( + "sensitive authentication diagnostics", + RequestFailedException( + 403, + "sensitive Azure diagnostics", + "AuthenticationFailed", + null + ) + ) + :> exn), + "Viewer dependency failure: ExceptionType=AuthenticationFailedException; AzureStatus=403; AzureErrorCode=AuthenticationFailed" + (fun () -> + CredentialUnavailableException( + "sensitive credential diagnostics" + ) + :> exn), + "Viewer dependency failure: ExceptionType=CredentialUnavailableException; AzureStatus=unavailable; AzureErrorCode=unavailable" + ] + |> List.iter (fun (createError, expectedLogMessage) -> + assertDependencyFailure + environmentName + createError + expectedLogMessage) + [<Test>] member _.``content preserves a self-contained active document``() = let blobName = $"{validPrefix}/self-contained.html" From b7fb037ff70a5cac6d03ef1b3e7d7b1d689cce46 Mon Sep 17 00:00:00 2001 From: Petr Pokorny <petr@innit.cz> Date: Tue, 18 Aug 2026 10:52:57 +0200 Subject: [PATCH 09/27] tm-canvas-safe-share-7ph Honor case-insensitive expiry metadata Look up the expiresOn blob metadata key case-insensitively so a casing variation from Azure Blob metadata no longer makes a live share resolve as malformed/expired. Adds regression coverage over ShareExpiry.isLive and ShareLookup.resolve with a mixed-case key, and records the case-insensitive match in the spec wire contract. Fixes focused-review finding F3. --- docs/spec/canvas-sharing.md | 2 +- src/CanvasShareViewer/ShareExpiry.fs | 10 +++++++++- src/Tests/CanvasShareViewerTests.fs | 30 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index 03195327..05baf6d8 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -175,7 +175,7 @@ only things they must agree on. They are pinned here rather than discovered per | Item | Value | |---|---| -| Expiry metadata | Blob metadata key `expiresOn`, value ISO-8601 UTC round-trip (`DateTimeOffset` `"o"`). A value that is absent or unparseable is malformed, not "never expires". | +| Expiry metadata | Blob metadata key `expiresOn`, matched case-insensitively per Azure Blob metadata semantics, with an ISO-8601 UTC round-trip (`DateTimeOffset` `"o"`) value. A value that is absent or unparseable is malformed, not "never expires". | | Opaque prefix segment | Exactly `CanvasShare.PrefixLength` (22) base62 characters (`[0-9A-Za-z]`). | | Filename segment | One path segment ending `.html`; no `/`, `\`, or `..`. This is the publisher's `leafName` output; URL composers percent-encode it as one URI path segment while Blob lookup uses the decoded filename. | | Not-found response | HTTP 404 with one fixed, content-free body, byte-identical for malformed, missing, and expired shares: identical status, headers, body, and response-emitting order. Elapsed time is not equalized -- a malformed path may be rejected before any storage access. | diff --git a/src/CanvasShareViewer/ShareExpiry.fs b/src/CanvasShareViewer/ShareExpiry.fs index ff637359..2861a9be 100644 --- a/src/CanvasShareViewer/ShareExpiry.fs +++ b/src/CanvasShareViewer/ShareExpiry.fs @@ -29,6 +29,14 @@ module internal ShareExpiry = let isLive now metadata = metadata - |> Map.tryFind MetadataKey + |> Map.tryPick (fun key value -> + if String.Equals( + key, + MetadataKey, + StringComparison.OrdinalIgnoreCase + ) then + Some value + else + None) |> Option.bind tryParseUtcRoundTrip |> Option.exists (fun expiresOn -> expiresOn > now) diff --git a/src/Tests/CanvasShareViewerTests.fs b/src/Tests/CanvasShareViewerTests.fs index b54fa068..be1297e6 100644 --- a/src/Tests/CanvasShareViewerTests.fs +++ b/src/Tests/CanvasShareViewerTests.fs @@ -448,6 +448,36 @@ type ShareExpiryTests() = "after expiry" )) + [<Test>] + member _.``ExpiresOn metadata is live through share lookup``() = + let now = expiresOn.AddTicks(-1L) + let mixedCaseMetadata = + Map [ "ExpiresOn", formatExpiry expiresOn ] + let blobName = $"{validPrefix}/report.html" + let stored = document "shared document" mixedCaseMetadata + let fake = + fakeBlobReader (Map [ blobName, stored ]) + + let result = + ShareLookup.resolve + fake.Reader + (fun () -> now) + validPrefix + "report.html" + CancellationToken.None + |> await + + Assert.Multiple(fun () -> + Assert.That( + ShareExpiry.isLive now mixedCaseMetadata, + Is.True + ) + + Assert.That( + result, + Is.EqualTo(Available stored) + )) + [<Test>] member _.``missing expiry metadata is malformed``() = Assert.That( From 680c95dfbb0ca7ffe837000f87b118451781b7de Mon Sep 17 00:00:00 2001 From: Petr Pokorny <petr@innit.cz> Date: Tue, 18 Aug 2026 11:23:32 +0200 Subject: [PATCH 10/27] tm-canvas-safe-share-069 Align publisher and viewer filenames Mirror one filename predicate on both sides of the share wire contract: non-empty, case-insensitive .html suffix, no path separators, consecutive dots allowed. Validate in WorktreeApi before path/file I/O and again at the CanvasShare.publish boundary, replacing the leafName rewrite so the original casing survives into the Blob name and viewer URL. Adds cross-contract and route tests plus spec updates. --- docs/spec/canvas-sharing.md | 20 +++-- src/CanvasShareViewer/SharePath.fs | 3 +- src/Server/CanvasShare.fs | 42 ++++++--- src/Server/WorktreeApi.fs | 12 +-- src/Tests/CanvasShareTests.fs | 129 ++++++++++++++++++++++++---- src/Tests/CanvasShareViewerTests.fs | 71 +++++++++++++-- 6 files changed, 223 insertions(+), 54 deletions(-) diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index 05baf6d8..d2a1b653 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -27,6 +27,10 @@ expiry, and returns a clean viewer URL; the client then writes the rich clipboard payload and shows a success banner (`Shared -- link copied`). A failure at any stage reuses the existing dismissible error banner. +- Before reading the source file or contacting Azure, the publisher requires a non-empty filename + segment with no path separator and an `.html` suffix matched case-insensitively. The original + filename casing is preserved for the exact Blob name and recipient URL; harmless consecutive + dots within the segment are accepted. - The button shows progress and refuses re-entry while a share is in flight: `CanvasState.ShareState` records the scoped worktree/doc and the `Publishing` or `WritingClipboard` phase, every Share button is disabled while that state is non-idle, only the matching scoped doc shows the spinner, @@ -112,10 +116,11 @@ share's expiry as blob metadata and returns a `CanvasShareResult` built from `viewerBaseUrl` plus the blob's existing unguessable-prefix-plus-filename naming (`<opaque-prefix>/<filename>`); it never mints or returns a SAS. -- `WorktreeApi.shareCanvasDocImpl` keeps its existing pipeline (validate path, read file, export, - publish) behind the same `withValidatedPath` guard that every other write method uses (mirroring - `archiveCanvasDoc`), and the demo-mode stub keeps returning `Error "... not available in demo - mode"`. +- `WorktreeApi.shareCanvasDocImpl` applies `CanvasShare.validateFilename` before path validation or + file access, then keeps the existing read, export, and publish pipeline behind the same + `withValidatedPath` guard that every other write method uses (mirroring `archiveCanvasDoc`). + `CanvasShare.publish` repeats that same validation at the upload boundary before configuration or + Azure work, and the demo-mode stub keeps returning `Error "... not available in demo mode"`. - `ShareCanvasDocRequest` and `CanvasShareResult` keep their existing shape (`WorktreePath`/ `Filename` in, `Url`/`Title` out); only the value and format of `Url` changes. - The Treemon server itself is unchanged: it stays bound to loopback and is never exposed to the @@ -177,7 +182,7 @@ only things they must agree on. They are pinned here rather than discovered per |---|---| | Expiry metadata | Blob metadata key `expiresOn`, matched case-insensitively per Azure Blob metadata semantics, with an ISO-8601 UTC round-trip (`DateTimeOffset` `"o"`) value. A value that is absent or unparseable is malformed, not "never expires". | | Opaque prefix segment | Exactly `CanvasShare.PrefixLength` (22) base62 characters (`[0-9A-Za-z]`). | -| Filename segment | One path segment ending `.html`; no `/`, `\`, or `..`. This is the publisher's `leafName` output; URL composers percent-encode it as one URI path segment while Blob lookup uses the decoded filename. | +| Filename segment | One non-empty path segment ending `.html`, compared with `StringComparison.OrdinalIgnoreCase`; no `/` or `\`. Internal consecutive dots are allowed because they cannot traverse without a separator. The publisher preserves the original filename casing, URL composers percent-encode it as one segment, and Blob lookup uses that decoded casing exactly. | | Not-found response | HTTP 404 with one fixed, content-free body, byte-identical for malformed, missing, and expired shares: identical status, headers, body, and response-emitting order. Elapsed time is not equalized -- a malformed path may be rejected before any storage access. | Response headers, by route: @@ -275,6 +280,7 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | Prefer a managed-identity federated credential over an Easy Auth client secret | Avoids minting, storing, or rotating a long-lived secret for the viewer's app registration. | | Store expiry as blob metadata rather than a separate data store | Keeps the expiry attached to the artifact it governs, with no second store to keep in sync; it travels and disappears with the blob. | | Re-check segments and expiry on the content route instead of trusting the shell | The recipient holds the URL, so the content route is directly reachable; a shell-only check would leave an expired share readable by editing the path. | +| Accept case-insensitive `.html` suffixes and consecutive dots in one filename segment | Windows can surface documents such as `Status.HTML`, and `release..notes.html` is not traversal once `/` and `\` are forbidden. Preserving the original casing keeps the generated URL and exact Blob lookup aligned, while rejecting invalid names before publish prevents successful uploads with dead viewer links. | | Put `sandbox allow-scripts` in the content route's CSP as well as on the iframe | The iframe attribute only covers the embedded case; the CSP directive also covers a signed-in recipient opening the content URL at top level, where the document would otherwise run on the viewer's authenticated origin. | | Look the blob up by exact composed name, never by listing or prefix search | A share URL then reveals only its own document; no reachable code path can turn one link into an inventory of the container. | | Allow `unsafe-eval` only inside the contained document response | Shared canvases already support arbitrary inline JavaScript; preserving `eval`/`new Function` compatibility does not grant viewer-origin or network access because the sandbox and remaining CSP directives still deny both. | @@ -294,8 +300,8 @@ remote-URL fetch that would otherwise be a working exfiltration channel. |---|---| | `src/Shared/Types.fs` | `ShareCanvasDocRequest`, `CanvasShareResult`, `IWorktreeApi.shareCanvasDoc` (shape unchanged) | | `src/Server/CanvasExport.fs` | Static export transform: base theme + no-op `canvasSend`; `extractTitle` / `resolveTitle` (unchanged) | -| `src/Server/CanvasShare.fs` | Blob upload, expiry metadata, and clean viewer-URL construction; no SAS | -| `src/Server/WorktreeApi.fs` | `shareCanvasDocImpl` + `withValidatedPath` wiring + demo-mode stub (unchanged) | +| `src/Server/CanvasShare.fs` | Publisher filename validation, Blob upload, expiry metadata, and clean viewer-URL construction; no SAS | +| `src/Server/WorktreeApi.fs` | Pre-I/O share filename/path gates, `shareCanvasDocImpl`, `withValidatedPath` wiring, and demo-mode stub | | `src/Server/GlobalConfig.fs` | `canvasShare` config: `accountName`, `container`, `defaultExpiryDays`, `viewerBaseUrl` | | `src/Server/HttpSecurity.fs` | Shared Remoting CSRF guard covering `shareCanvasDoc` (unchanged) | | `src/Client/CanvasPane.fs`, `CanvasState.fs`, `CanvasUpdate.fs`, `index.html` | Share button, `ShareState` phase machine, clipboard write and banner routing (unchanged) | diff --git a/src/CanvasShareViewer/SharePath.fs b/src/CanvasShareViewer/SharePath.fs index 4464d3ad..7789204d 100644 --- a/src/CanvasShareViewer/SharePath.fs +++ b/src/CanvasShareViewer/SharePath.fs @@ -24,10 +24,9 @@ module internal SharePath = let validFilename = not (String.IsNullOrEmpty(filename)) - && filename.EndsWith(".html", StringComparison.Ordinal) + && filename.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && not (filename.Contains('/')) && not (filename.Contains('\\')) - && not (filename.Contains("..", StringComparison.Ordinal)) if validPrefix && validFilename then Some diff --git a/src/Server/CanvasShare.fs b/src/Server/CanvasShare.fs index 750f1734..c81ddbcb 100644 --- a/src/Server/CanvasShare.fs +++ b/src/Server/CanvasShare.fs @@ -38,23 +38,34 @@ let internal PrefixLength = 22 [<Literal>] let internal ExpiryMetadataKey = "expiresOn" +/// Fixed validation error for filenames that cannot be redeemed by the viewer. Kept free of the +/// caller-supplied value so malformed paths are not reflected into logs or client diagnostics. +[<Literal>] +let internal InvalidFilenameMessage = + "Invalid filename: shared canvas docs must use one filename segment ending in .html." + +/// The publisher half of the filename wire contract. Extension matching is case-insensitive, but +/// the filename itself is never normalized: its original casing must survive the URL and exact Blob +/// lookup. Once both path separators are forbidden, consecutive dots inside the segment are inert. +let internal validateFilename (filename: string) : Result<unit, string> = + let valid = + not (String.IsNullOrEmpty(filename)) + && filename.EndsWith(".html", StringComparison.OrdinalIgnoreCase) + && not (filename.Contains('/')) + && not (filename.Contains('\\')) + + if valid then Ok() else Error InvalidFilenameMessage + /// A fresh high-entropy base62 prefix from the cryptographic RNG. `GetString` samples the alphabet /// uniformly (no modulo bias). Impure (RNG) but shape-testable: right length, alphabet-only, and /// distinct across calls. let internal generatePrefix () : string = System.Security.Cryptography.RandomNumberGenerator.GetString(base62Alphabet.AsSpan(), PrefixLength) -/// The leaf of a filename — defends the blob name against a caller passing a doc path rather than a -/// bare name. The live caller validates first, but publishing must never silently create nested -/// blobs from `..`/subdirs. Pure. -let internal leafName (filename: string) : string = - filename.Replace('\\', '/').Split('/') |> Array.last - -/// The blob name a published doc lands at: `<random-prefix>/<filename-leaf>`. The random prefix gives -/// uniqueness + unguessability; the real filename gives the recipient a meaningful page/tab title. -/// Pure given the prefix, so the naming shape is unit-testable. +/// The blob name a published doc lands at: `<random-prefix>/<filename>`. `publish` admits only one +/// validated filename segment, so this preserves that segment exactly for the viewer's lookup. let internal blobName (prefix: string) (filename: string) : string = - $"{prefix}/{leafName filename}" + $"{prefix}/{filename}" /// Builds the upload contract shared with the viewer: UTF-8 HTML plus an exact `expiresOn` /// metadata value in UTC round-trip form. @@ -68,9 +79,10 @@ let internal buildUploadOptions (expiresOn: DateTimeOffset) = BlobUploadOptions(HttpHeaders = headers, Metadata = metadata) /// Constructs a recipient URL without consulting the Blob URI. The filename is encoded as one path -/// segment, and validated config guarantees the base has no query or fragment to carry through. +/// segment without changing its casing, and validated config guarantees the base has no query or +/// fragment to carry through. let internal buildViewerUrl (viewerBaseUrl: Uri) (prefix: string) (filename: string) = - let encodedFilename = filename |> leafName |> Uri.EscapeDataString + let encodedFilename = Uri.EscapeDataString filename $"{viewerBaseUrl.AbsoluteUri.TrimEnd('/')}/c/{prefix}/{encodedFilename}" let private credential = lazy (AzureCliCredential()) @@ -104,10 +116,12 @@ let internal signInRequiredMessage = /// /// Uploads `html` to the configured pre-provisioned private container at /// `<random-prefix>/<filename>`, with UTF-8 HTML headers and the exact expiry metadata the viewer -/// enforces. Returns `Error` (never throws) when either endpoint is unconfigured, the host has no -/// usable delegated identity, or storage fails. Returned errors and logs contain no recipient URL. +/// enforces. Filename validation runs before configuration or Azure work. Returns `Error` (never +/// throws) when the filename is invalid, either endpoint is unconfigured, the host has no usable +/// delegated identity, or storage fails. Returned errors and logs contain no recipient URL. let publish (filename: string) (html: string) : Async<Result<string, string>> = asyncResult { + do! validateFilename filename let config = readCanvasShareConfig () let! accountName = config.AccountName |> Result.requireSome notConfiguredMessage let! viewerBaseUrl = config.ViewerBaseUrl |> Result.requireSome notConfiguredMessage diff --git a/src/Server/WorktreeApi.fs b/src/Server/WorktreeApi.fs index 6fdc16f2..9f5c2575 100644 --- a/src/Server/WorktreeApi.fs +++ b/src/Server/WorktreeApi.fs @@ -106,16 +106,18 @@ let private archiveCanvasDocImpl (request: ArchiveCanvasDocRequest) = File.Move(sourcePath, destPath, overwrite = true) } -/// Share a canvas doc: validate the path → read the on-disk file → static-export it -/// (`CanvasExport.buildStaticHtml` re-injects theme + no-op canvasSend) → publish to Azure Blob and -/// record its expiry (`CanvasShare.publish`) → assemble the `CanvasShareResult` with the clean viewer -/// URL and the doc's resolved title. Mirrors `archiveCanvasDocImpl`. `Title` uses +/// Share a canvas doc: validate the viewer-compatible filename and path → read the on-disk file → +/// static-export it (`CanvasExport.buildStaticHtml` re-injects theme + no-op canvasSend) → publish +/// to Azure Blob and record its expiry (`CanvasShare.publish`) → assemble the `CanvasShareResult` +/// with the clean viewer URL and the doc's resolved title. Mirrors `archiveCanvasDocImpl`. `Title` uses /// `CanvasExport.resolveTitle` (the doc's `<title>`, falling back to a prettified filename) because /// `CanvasShareResult.Title` is a plain string, not an option; the title is read from the original /// HTML (`buildStaticHtml` injects only at `</head>`, so it never alters the doc's `<title>`). -let private shareCanvasDocImpl (request: ShareCanvasDocRequest) : Async<Result<CanvasShareResult, string>> = +let internal shareCanvasDocImpl (request: ShareCanvasDocRequest) : Async<Result<CanvasShareResult, string>> = let path = WorktreePath.value request.WorktreePath asyncResult { + do! Server.CanvasShare.validateFilename request.Filename + let! sourcePath = Server.PathUtils.validateCanvasPath path request.Filename |> Result.mapError (fun _ -> "Invalid filename: path escapes canvas directory") diff --git a/src/Tests/CanvasShareTests.fs b/src/Tests/CanvasShareTests.fs index d17b940f..4166427a 100644 --- a/src/Tests/CanvasShareTests.fs +++ b/src/Tests/CanvasShareTests.fs @@ -7,11 +7,85 @@ open NUnit.Framework open Server open Server.CanvasShare open Server.GlobalConfig +open Shared open Tests.TestUtils // Pure publisher contracts plus the fail-before-network configuration gate. The real Azure // round-trip is covered by the deployment verification described in docs/spec/canvas-sharing.md. +[<TestFixture>] +[<Category("Unit")>] +[<Category("Fast")>] +type ShareFilenameContractTests() = + + let validPrefix = "0123456789AbCdEfGhIjKl" + + [<TestCase("status.html")>] + [<TestCase("Status.HTML")>] + [<TestCase("release..notes.html")>] + member _.``publisher and viewer accept the same valid filename``(filename: string) = + Assert.Multiple(fun () -> + Assert.That( + validateFilename filename |> Result.isOk, + Is.True, + "publisher") + Assert.That( + CanvasShareViewer.SharePath.tryCreate + validPrefix + filename + |> Option.isSome, + Is.True, + "viewer") + Assert.That( + blobName validPrefix filename, + Is.EqualTo($"{validPrefix}/{filename}"), + "exact Blob name")) + + [<TestCase("")>] + [<TestCase("notes.txt")>] + [<TestCase("folder/notes.html")>] + [<TestCase(@"folder\notes.html")>] + [<TestCase("../notes.html")>] + member _.``publisher and viewer reject the same invalid filename``(filename: string) = + let publisherError = + match validateFilename filename with + | Error error -> error + | Ok() -> + Assert.Fail($"Publisher accepted invalid filename '{filename}'.") + "" + + Assert.Multiple(fun () -> + Assert.That( + publisherError, + Is.EqualTo(InvalidFilenameMessage), + "publisher") + Assert.That( + CanvasShareViewer.SharePath.tryCreate + validPrefix + filename + |> Option.isNone, + Is.True, + "viewer")) + + [<TestCase("notes.txt")>] + [<TestCase("folder/notes.html")>] + [<TestCase(@"folder\notes.html")>] + [<TestCase("../notes.html")>] + member _.``share API rejects invalid filename before file or upload work``(filename: string) = + withTempDir "canvas-share-filename" (fun worktreePath -> + let request = + { WorktreePath = WorktreePath worktreePath + Filename = filename } + + match WorktreeApi.shareCanvasDocImpl request |> runAsync with + | Error error -> + Assert.That( + error, + Is.EqualTo(InvalidFilenameMessage)) + | Ok result -> + Assert.Fail( + $"Expected invalid filename Error before publishing, got Ok {result}")) + [<TestFixture>] [<Category("Unit")>] [<Category("Fast")>] @@ -26,20 +100,10 @@ type BlobNamingTests() = Assert.That(blobName "abc" "weekly-sync.html", Does.EndWith("/weekly-sync.html")) [<Test>] - member _.``blobName uses only the leaf so a nested path cannot create nested blobs``() = - Assert.That(blobName "P" "sub/dir/x.html", Is.EqualTo("P/x.html")) - - [<Test>] - member _.``leafName strips a forward-slash directory``() = - Assert.That(leafName "a/b/c.html", Is.EqualTo("c.html")) - - [<Test>] - member _.``leafName strips a backslash directory``() = - Assert.That(leafName @"a\b\c.html", Is.EqualTo("c.html")) - - [<Test>] - member _.``leafName leaves a bare filename untouched``() = - Assert.That(leafName "build-status.html", Is.EqualTo("build-status.html")) + member _.``blobName preserves filename casing and consecutive dots``() = + Assert.That( + blobName "P" "Release..Notes.HTML", + Is.EqualTo("P/Release..Notes.HTML")) [<Test>] member _.``generatePrefix is PrefixLength base62 characters``() = @@ -110,8 +174,10 @@ type ViewerUrlTests() = let prefix = "0123456789AbCdEfGhIjKl" - [<Test>] - member _.``publisher naming satisfies the viewer wire contract``() = + [<TestCase("status.html")>] + [<TestCase("Status.HTML")>] + [<TestCase("release..notes.html")>] + member _.``publisher naming satisfies the viewer wire contract``(filename: string) = Assert.Multiple(fun () -> Assert.That( PrefixLength, @@ -119,7 +185,7 @@ type ViewerUrlTests() = Assert.That( CanvasShareViewer.SharePath.tryCreate (generatePrefix ()) - (leafName "nested/status.html") + filename |> Option.isSome, Is.True)) @@ -155,7 +221,7 @@ type ViewerUrlTests() = buildViewerUrl (Uri("https://viewer.test")) prefix - "nested/Q3 report #1.html" + "Q3 report #1.html" Assert.That( url, @@ -164,6 +230,20 @@ type ViewerUrlTests() = + prefix + "/Q3%20report%20%231.html")) + [<TestCase("Status.HTML")>] + [<TestCase("release..notes.html")>] + member _.``viewer URL preserves the exact compatible filename``(filename: string) = + let url = + buildViewerUrl + (Uri("https://viewer.test")) + prefix + filename + + Assert.That( + url, + Is.EqualTo( + $"https://viewer.test/c/{prefix}/{filename}")) + [<Test>] member _.``viewer URL has no query fragment or Blob credential``() = let url = @@ -306,6 +386,19 @@ type PublishConfigGateTests() = let seed (dir: string) (json: string) = File.WriteAllText(Path.Combine(dir, "config.json"), json) + [<TestCase("notes.txt")>] + [<TestCase("folder/notes.html")>] + [<TestCase(@"folder\notes.html")>] + [<TestCase("../notes.html")>] + member _.``publish rejects an invalid filename before configuration or Azure``(filename: string) = + withTempConfigDir "canvas-share-publish" (fun _ -> + match runAsync (publish filename "<html></html>") with + | Error msg -> + Assert.That(msg, Is.EqualTo(InvalidFilenameMessage)) + | Ok url -> + Assert.Fail( + $"expected invalid filename Error before publishing, got Ok {url}")) + [<Test>] member _.``serviceClient reuses one Azure authentication pipeline per account``() = let first = serviceClient "tmcanvasabc" diff --git a/src/Tests/CanvasShareViewerTests.fs b/src/Tests/CanvasShareViewerTests.fs index be1297e6..d83f7303 100644 --- a/src/Tests/CanvasShareViewerTests.fs +++ b/src/Tests/CanvasShareViewerTests.fs @@ -379,7 +379,6 @@ type SharePathValidationTests() = [<TestCase("../report.html")>] [<TestCase("folder/report.html")>] [<TestCase(@"folder\report.html")>] - [<TestCase("report..html")>] member _.``filename rejects traversal``(filename: string) = Assert.That( SharePath.tryCreate validPrefix filename @@ -387,16 +386,21 @@ type SharePathValidationTests() = Is.True ) - [<Test>] - member _.``filename must end with lowercase html``() = + [<TestCase("Status.HTML")>] + [<TestCase("release..notes.html")>] + member _.``filename accepts case-insensitive html suffix and consecutive dots``(filename: string) = Assert.That( - SharePath.tryCreate validPrefix "report.txt" - |> Option.isNone, - Is.True + SharePath.tryCreate validPrefix filename + |> Option.map SharePath.blobName, + Is.EqualTo(Some $"{validPrefix}/{filename}") ) + [<TestCase("")>] + [<TestCase("report.txt")>] + [<TestCase("report.html.txt")>] + member _.``filename must end with html``(filename: string) = Assert.That( - SharePath.tryCreate validPrefix "report.HTML" + SharePath.tryCreate validPrefix filename |> Option.isNone, Is.True ) @@ -883,6 +887,57 @@ type ViewerRouteTests() = ) ))) + [<TestCase("Status.HTML")>] + [<TestCase("release..notes.html")>] + member _.``publisher-compatible filename works on shell and content routes``(filename: string) = + let encodedFilename = Uri.EscapeDataString(filename) + let blobName = $"{validPrefix}/{filename}" + let body = $"content for {filename}" + + let documents = + Map [ + blobName, + document body liveMetadata + ] + + withViewer documents now (fun fake client baseUrl -> + use shell = + client.GetAsync( + $"{baseUrl}/c/{validPrefix}/{encodedFilename}" + ) + |> await + + use content = + client.GetAsync( + $"{baseUrl}/c/{validPrefix}/{encodedFilename}/content" + ) + |> await + + Assert.Multiple(fun () -> + Assert.That( + shell.StatusCode, + Is.EqualTo(HttpStatusCode.OK), + "shell route" + ) + + Assert.That( + content.StatusCode, + Is.EqualTo(HttpStatusCode.OK), + "content route" + ) + + Assert.That( + content.Content.ReadAsStringAsync() + |> await, + Is.EqualTo(body) + ) + + Assert.That( + fake.Requests(), + Is.EqualTo([ blobName; blobName ]), + "both routes must preserve exact filename casing for Blob lookup" + ))) + [<TestCase("Production")>] [<TestCase("Development")>] member _.``storage failures return a fixed empty 503 in every environment``(environmentName: string) = @@ -1034,7 +1089,7 @@ type ViewerRouteTests() = [ "short/report.html", ShareLookup.InvalidPathProbeBlobName - $"{validPrefix}/report..html", + $"{validPrefix}/report.txt", ShareLookup.InvalidPathProbeBlobName $"{validPrefix}/missing.html", $"{validPrefix}/missing.html" From 0853e324bef07b5f43b0b85eb2f5ad7935e361b6 Mon Sep 17 00:00:00 2001 From: Petr Pokorny <petr@innit.cz> Date: Tue, 18 Aug 2026 11:28:13 +0200 Subject: [PATCH 11/27] tm-canvas-safe-share-266 Deny cross-origin shell framing Append frame-ancestors 'none' to the shell route CSP so the viewer shell cannot be framed cross-origin, matching focused-review finding F7. Update the exact-header test expectation and the shell wire-contract row in the spec. --- docs/spec/canvas-sharing.md | 2 +- src/CanvasShareViewer/ViewerApplication.fs | 2 +- src/Tests/CanvasShareViewerTests.fs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index d2a1b653..922ceb0a 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -189,7 +189,7 @@ Response headers, by route: | Route | Headers | |---|---| -| Shell | `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; form-action 'none'; base-uri 'none'`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store` | +| Shell | `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store` | | Content | `Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; sandbox allow-scripts`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store` | | Dependency failure (either route) | HTTP 503 with an empty body and `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store` | diff --git a/src/CanvasShareViewer/ViewerApplication.fs b/src/CanvasShareViewer/ViewerApplication.fs index 4f8e0d11..6ab5f2b3 100644 --- a/src/CanvasShareViewer/ViewerApplication.fs +++ b/src/CanvasShareViewer/ViewerApplication.fs @@ -27,7 +27,7 @@ module internal ViewerApplication = [<Literal>] let private ShellContentSecurityPolicy = - "default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; form-action 'none'; base-uri 'none'" + "default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'" [<Literal>] let private ContentContentSecurityPolicy = diff --git a/src/Tests/CanvasShareViewerTests.fs b/src/Tests/CanvasShareViewerTests.fs index d83f7303..ba960f95 100644 --- a/src/Tests/CanvasShareViewerTests.fs +++ b/src/Tests/CanvasShareViewerTests.fs @@ -26,7 +26,7 @@ open Tests.TestUtils let private validPrefix = "0123456789ABCDEFGHIJKL" let private shellContentSecurityPolicy = - "default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; form-action 'none'; base-uri 'none'" + "default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'" let private contentContentSecurityPolicy = "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; sandbox allow-scripts" From c76cd79e1e13c5b7a730a480da7d7e4615ab2f17 Mon Sep 17 00:00:00 2001 From: Petr Pokorny <petr@innit.cz> Date: Tue, 18 Aug 2026 12:20:04 +0200 Subject: [PATCH 12/27] tm-canvas-safe-share-o88 Avoid duplicate viewer Blob downloads Split the viewer storage boundary so a page load no longer downloads each shared document twice. BlobReader gains a properties-only exact lookup backed by GetPropertiesAsync; the shell route resolves against it while the content route keeps the single body-bearing read. Both routes still re-validate the path and re-check expiry independently. Malformed paths now short-circuit to not-found without any storage call, removing the InvalidPathProbeBlobName probe under the recorded timing decision the spec already documents. --- docs/spec/canvas-sharing.md | 21 ++-- src/CanvasShareViewer/BlobStorage.fs | 69 +++++++++--- src/CanvasShareViewer/ShareLookup.fs | 82 +++++++++----- src/CanvasShareViewer/ViewerApplication.fs | 17 +-- src/Tests/CanvasShareViewerTests.fs | 124 ++++++++++++++++----- 5 files changed, 217 insertions(+), 96 deletions(-) diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index 922ceb0a..620a663d 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -138,18 +138,20 @@ tenant authenticates, including B2B guests, passes the gate. The registration authenticates to Easy Auth via a managed-identity federated credential rather than a long-lived client secret. - Two routes divide responsibility: a shell route (`/c/<opaque-prefix>/<filename>`) validates the - request and expiry and renders a minimal HTML page; a content route - (`/c/<opaque-prefix>/<filename>/content`) streams the document bytes and is the only thing the - shell's iframe loads. Keeping them separate lets the content response carry a much stricter - policy than the shell needs. + request and expiry through an exact Blob properties lookup and renders a minimal HTML page + without downloading the document body; a content route + (`/c/<opaque-prefix>/<filename>/content`) performs the only body-bearing lookup and is the only + thing the shell's iframe loads. Keeping them separate lets the content response carry a much + stricter policy than the shell needs. - Each route re-validates the segments and re-checks expiry against blob metadata on its own; the content route never trusts that the shell already checked. Otherwise the content route would be an unguarded bypass for an expired or malformed share whose URL the recipient still holds. - A matched route collapses path validity, Blob existence, and expiry into the single not-found - outcome: a valid path performs one exact read of `<prefix>/<filename>`, while malformed segments - resolve to not-found before any storage access, so untrusted dot segments never reach Blob URI - construction. Every not-found path then emits the identical response through the same - application-level ordering; only elapsed time may differ. + outcome: a valid shell path performs one exact `GetPropertiesAsync` call and a valid content path + performs one exact body read of `<prefix>/<filename>`, while malformed segments resolve to + not-found before any storage access, so untrusted dot segments never reach Blob URI construction. + Every not-found path then emits the identical response through the same application-level + ordering; only elapsed time may differ. - An exception boundary registered before routing handles non-404 Azure Storage failures and `DefaultAzureCredential` failures independently of the ASP.NET Core environment. It logs only the exception type and available Azure status/error code, clears the route response, and emits @@ -280,6 +282,7 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | Prefer a managed-identity federated credential over an Easy Auth client secret | Avoids minting, storing, or rotating a long-lived secret for the viewer's app registration. | | Store expiry as blob metadata rather than a separate data store | Keeps the expiry attached to the artifact it governs, with no second store to keep in sync; it travels and disappears with the blob. | | Re-check segments and expiry on the content route instead of trusting the shell | The recipient holds the URL, so the content route is directly reachable; a shell-only check would leave an expired share readable by editing the path. | +| Use a properties-only Blob lookup for the shell and reserve the body read for the content route | The shell needs only existence and expiry metadata, so downloading and discarding the complete document there would double the transferred document bytes without strengthening validation. | | Accept case-insensitive `.html` suffixes and consecutive dots in one filename segment | Windows can surface documents such as `Status.HTML`, and `release..notes.html` is not traversal once `/` and `\` are forbidden. Preserving the original casing keeps the generated URL and exact Blob lookup aligned, while rejecting invalid names before publish prevents successful uploads with dead viewer links. | | Put `sandbox allow-scripts` in the content route's CSP as well as on the iframe | The iframe attribute only covers the embedded case; the CSP directive also covers a signed-in recipient opening the content URL at top level, where the document would otherwise run on the viewer's authenticated origin. | | Look the blob up by exact composed name, never by listing or prefix search | A share URL then reveals only its own document; no reachable code path can turn one link into an inventory of the container. | @@ -326,6 +329,8 @@ remote-URL fetch that would otherwise be a working exfiltration channel. deletion runs. - The content route enforces the same checks as the shell: an expired or malformed share is denied on the content route too, and a document opened directly at the content URL is still sandboxed. +- A normal shell-plus-content page load performs one properties-only exact Blob lookup and one + body-bearing exact read; the shell never downloads or buffers the document body. - Throwing storage and credential readers produce the same empty policy-headered 503 on shell and content routes in both Production and Development, with no framework diagnostic response. - Deleting or clearing a document's backing blob denies its link immediately (revocation). diff --git a/src/CanvasShareViewer/BlobStorage.fs b/src/CanvasShareViewer/BlobStorage.fs index f2f9fb49..7ecd54a7 100644 --- a/src/CanvasShareViewer/BlobStorage.fs +++ b/src/CanvasShareViewer/BlobStorage.fs @@ -14,7 +14,11 @@ type internal BlobDocument = Metadata: Map<string, string> } type internal BlobReader = - { ReadExact: + { ReadPropertiesExact: + string -> + CancellationToken -> + Task<Map<string, string> option> + ReadExact: string -> CancellationToken -> Task<BlobDocument option> } @@ -38,6 +42,20 @@ module internal BlobStorage = |> Seq.map (fun pair -> pair.Key, pair.Value) |> Map.ofSeq + let private tryReadExact + (read: unit -> Task<'value>) + : Task<'value option> = + task { + try + let! value = read () + return Some value + with + | :? RequestFailedException as ex when + isMissingBlobFailure ex + -> + return None + } + let azure (configuration: ViewerConfiguration) (credential: TokenCredential) @@ -53,26 +71,41 @@ module internal BlobStorage = let containerClient = serviceClient.GetBlobContainerClient(configuration.ShareContainer) - { ReadExact = + { ReadPropertiesExact = + fun blobName cancellationToken -> + tryReadExact + (fun () -> + task { + let! response = + containerClient + .GetBlobClient(blobName) + .GetPropertiesAsync( + cancellationToken = + cancellationToken + ) + + return + response.Value.Metadata + |> metadataMap + }) + ReadExact = fun blobName cancellationToken -> - task { - try - let! response = - containerClient - .GetBlobClient(blobName) - .DownloadContentAsync(cancellationToken) + tryReadExact + (fun () -> + task { + let! response = + containerClient + .GetBlobClient(blobName) + .DownloadContentAsync( + cancellationToken + ) - let download = response.Value + let download = response.Value - return - Some - { Content = download.Content.ToMemory() + return + { Content = + download.Content.ToMemory() Metadata = download.Details.Metadata |> metadataMap } - with - | :? RequestFailedException as ex when - isMissingBlobFailure ex - -> - return None - } } + }) } diff --git a/src/CanvasShareViewer/ShareLookup.fs b/src/CanvasShareViewer/ShareLookup.fs index ccdf9320..3a2f02e4 100644 --- a/src/CanvasShareViewer/ShareLookup.fs +++ b/src/CanvasShareViewer/ShareLookup.fs @@ -4,44 +4,68 @@ open System open System.Threading open System.Threading.Tasks -type internal ShareLookupResult = - | Available of BlobDocument +type internal ShareLookupResult<'stored> = + | Available of 'stored | NotFound module internal ShareLookup = - [<Literal>] - let InvalidPathProbeBlobName = "_invalid-path-probe" - - let resolve - (reader: BlobReader) + let private resolve + read + metadata (clock: unit -> DateTimeOffset) prefix filename (cancellationToken: CancellationToken) - : Task<ShareLookupResult> = + : Task<ShareLookupResult<'stored>> = task { - let path = SharePath.tryCreate prefix filename - - let exactBlobName = - path - |> Option.map SharePath.blobName - |> Option.defaultValue InvalidPathProbeBlobName + match SharePath.tryCreate prefix filename with + | None -> + return NotFound + | Some path -> + let! stored = + read + (SharePath.blobName path) + cancellationToken - let! stored = - reader.ReadExact exactBlobName cancellationToken - - let metadata = - stored - |> Option.map _.Metadata - |> Option.defaultValue Map.empty + return + match stored with + | Some value + when ShareExpiry.isLive + (clock ()) + (metadata value) + -> + Available value + | _ -> + NotFound + } - let live = ShareExpiry.isLive (clock ()) metadata + let resolveProperties + (reader: BlobReader) + clock + prefix + filename + cancellationToken + = + resolve + reader.ReadPropertiesExact + id + clock + prefix + filename + cancellationToken - return - match path, stored, live with - | Some _, Some document, true -> - Available document - | _ -> - NotFound - } + let resolveDocument + (reader: BlobReader) + clock + prefix + filename + cancellationToken + = + resolve + reader.ReadExact + _.Metadata + clock + prefix + filename + cancellationToken diff --git a/src/CanvasShareViewer/ViewerApplication.fs b/src/CanvasShareViewer/ViewerApplication.fs index 6ab5f2b3..612d4c68 100644 --- a/src/CanvasShareViewer/ViewerApplication.fs +++ b/src/CanvasShareViewer/ViewerApplication.fs @@ -145,7 +145,7 @@ module internal ViewerApplication = let private writeShell (context: HttpContext) - (_document: BlobDocument) + (_metadata: Map<string, string>) : Task = task { let content = shellBytes context @@ -173,10 +173,9 @@ module internal ViewerApplication = } let private handle - (reader: BlobReader) - (clock: unit -> DateTimeOffset) + resolve contentSecurityPolicy - (render: HttpContext -> BlobDocument -> Task) + (render: HttpContext -> 'stored -> Task) (context: HttpContext) : Task = task { @@ -186,9 +185,7 @@ module internal ViewerApplication = let filename = routeSegment "filename" context let! result = - ShareLookup.resolve - reader - clock + resolve prefix filename context.RequestAborted @@ -222,8 +219,7 @@ module internal ViewerApplication = ContentRoute, RequestDelegate( handle - reader - clock + (ShareLookup.resolveDocument reader clock) ContentContentSecurityPolicy writeContent ) @@ -234,8 +230,7 @@ module internal ViewerApplication = ShellRoute, RequestDelegate( handle - reader - clock + (ShareLookup.resolveProperties reader clock) ShellContentSecurityPolicy writeShell ) diff --git a/src/Tests/CanvasShareViewerTests.fs b/src/Tests/CanvasShareViewerTests.fs index ba960f95..df8a9b60 100644 --- a/src/Tests/CanvasShareViewerTests.fs +++ b/src/Tests/CanvasShareViewerTests.fs @@ -47,9 +47,13 @@ let private document |> ReadOnlyMemory<byte> Metadata = metadata } +type private BlobLookup = + | PropertiesLookup of string + | ContentRead of string + type private FakeBlobReader = { Reader: BlobReader - Requests: unit -> string list } + Requests: unit -> BlobLookup list } type private CapturedLog = { Level: LogLevel @@ -103,9 +107,21 @@ let private fakeBlobReader documents = let mutable requestsRev = [] { Reader = - { ReadExact = + { ReadPropertiesExact = + fun blobName _ -> + requestsRev <- + PropertiesLookup blobName + :: requestsRev + + documents + |> Map.tryFind blobName + |> Option.map _.Metadata + |> Task.FromResult + ReadExact = fun blobName _ -> - requestsRev <- blobName :: requestsRev + requestsRev <- + ContentRead blobName :: requestsRev + documents |> Map.tryFind blobName |> Task.FromResult } @@ -168,7 +184,11 @@ let private withThrowingViewer |> ignore let reader = - { ReadExact = + { ReadPropertiesExact = + fun _ _ -> + createError () + |> Task.FromException<Map<string, string> option> + ReadExact = fun _ _ -> createError () |> Task.FromException<BlobDocument option> } @@ -453,7 +473,7 @@ type ShareExpiryTests() = )) [<Test>] - member _.``ExpiresOn metadata is live through share lookup``() = + member _.``ExpiresOn metadata is live through both share lookups``() = let now = expiresOn.AddTicks(-1L) let mixedCaseMetadata = Map [ "ExpiresOn", formatExpiry expiresOn ] @@ -462,8 +482,17 @@ type ShareExpiryTests() = let fake = fakeBlobReader (Map [ blobName, stored ]) - let result = - ShareLookup.resolve + let propertiesResult = + ShareLookup.resolveProperties + fake.Reader + (fun () -> now) + validPrefix + "report.html" + CancellationToken.None + |> await + + let documentResult = + ShareLookup.resolveDocument fake.Reader (fun () -> now) validPrefix @@ -478,8 +507,25 @@ type ShareExpiryTests() = ) Assert.That( - result, + propertiesResult, + Is.EqualTo( + Available mixedCaseMetadata + ) + ) + + Assert.That( + documentResult, Is.EqualTo(Available stored) + ) + + Assert.That( + fake.Requests(), + Is.EqualTo( + [ + PropertiesLookup blobName + ContentRead blobName + ] + ) )) [<Test>] @@ -725,7 +771,7 @@ type ViewerRouteTests() = ) [<Test>] - member _.``shell routes only to sandboxed content and both routes re-read the blob``() = + member _.``shell uses properties while sandboxed content reads the body``() = let filename = "report & \"notes\".html" let encodedFilename = Uri.EscapeDataString(filename) let blobName = $"{validPrefix}/{filename}" @@ -823,8 +869,13 @@ type ViewerRouteTests() = Assert.That( fake.Requests(), - Is.EqualTo([ blobName; blobName ]), - "each route must perform its own exact read" + Is.EqualTo( + [ + PropertiesLookup blobName + ContentRead blobName + ] + ), + "the shell must read only properties and the content route must perform the only body read" ))) [<Test>] @@ -934,8 +985,13 @@ type ViewerRouteTests() = Assert.That( fake.Requests(), - Is.EqualTo([ blobName; blobName ]), - "both routes must preserve exact filename casing for Blob lookup" + Is.EqualTo( + [ + PropertiesLookup blobName + ContentRead blobName + ] + ), + "both lookup kinds must preserve exact filename casing" ))) [<TestCase("Production")>] @@ -1057,6 +1113,7 @@ type ViewerRouteTests() = [<Test>] member _.``all not-found outcomes are indistinguishable on both routes``() = + let missingName = $"{validPrefix}/missing.html" let expiredName = $"{validPrefix}/expired.html" let missingExpiryName = $"{validPrefix}/missing-expiry.html" @@ -1065,8 +1122,6 @@ type ViewerRouteTests() = let documents = Map [ - ShareLookup.InvalidPathProbeBlobName, - document "reserved probe" liveMetadata expiredName, document "expired" @@ -1087,18 +1142,28 @@ type ViewerRouteTests() = let cases = [ - "short/report.html", - ShareLookup.InvalidPathProbeBlobName - $"{validPrefix}/report.txt", - ShareLookup.InvalidPathProbeBlobName - $"{validPrefix}/missing.html", - $"{validPrefix}/missing.html" - $"{validPrefix}/expired.html", - expiredName - $"{validPrefix}/missing-expiry.html", - missingExpiryName - $"{validPrefix}/bad-expiry.html", - badExpiryName + "short/report.html", [] + $"{validPrefix}/report.txt", [] + missingName, + [ + PropertiesLookup missingName + ContentRead missingName + ] + expiredName, + [ + PropertiesLookup expiredName + ContentRead expiredName + ] + missingExpiryName, + [ + PropertiesLookup missingExpiryName + ContentRead missingExpiryName + ] + badExpiryName, + [ + PropertiesLookup badExpiryName + ContentRead badExpiryName + ] ] withViewer documents now (fun fake client baseUrl -> @@ -1152,10 +1217,9 @@ type ViewerRouteTests() = fake.Requests(), Is.EqualTo( cases - |> List.collect (fun (_, blobName) -> - [ blobName; blobName ]) + |> List.collect snd ), - "malformed, missing, and expired requests must follow the same exact-read ordering on each route" + "malformed paths must skip storage while each valid path performs one properties lookup and one body read" ))) [<Test>] From b1938e6e93cc7658c828d935bf3c6de8e485655d Mon Sep 17 00:00:00 2001 From: Petr Pokorny <petr@innit.cz> Date: Tue, 18 Aug 2026 12:25:26 +0200 Subject: [PATCH 13/27] tm-canvas-safe-share-ve1 Require root viewer base URLs Reject canvasShare.viewerBaseUrl values whose path is not exactly "/", so published links can never point at routes the root-mounted viewer does not serve (focused-review finding F9 / A-07). Spec and tests updated to match. --- docs/spec/canvas-sharing.md | 6 ++++-- src/Server/GlobalConfig.fs | 5 +++-- src/Tests/CanvasShareTests.fs | 11 +++++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index 620a663d..6f431339 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -97,8 +97,10 @@ tenant/client/resource identifier or secret, ships as a value in the repository's defaults. `accountName` and `viewerBaseUrl` have no default, so their absence means the feature is unconfigured; the canonical deployed value of `viewerBaseUrl` is - `https://treemon.azurewebsites.net`. An unconfigured Share action still fails with a clear - `Result.Error` before any network call. + `https://treemon.azurewebsites.net`. The URL must be an HTTPS origin whose parsed path is exactly + `/`, with no user info, query, or fragment; path-based viewer URLs are rejected because the + deployed viewer serves `/c/...` only at the origin root. An unconfigured Share action still + fails with a clear `Result.Error` before any network call. - `defaultExpiryDays` remains 7 and `maxCanvasShareExpiryDays` is 30. The share container's Blob lifecycle policy deletes only after 31 days or more, so cleanup never removes a document the viewer would still have served. diff --git a/src/Server/GlobalConfig.fs b/src/Server/GlobalConfig.fs index deb0e3c3..f296f778 100644 --- a/src/Server/GlobalConfig.fs +++ b/src/Server/GlobalConfig.fs @@ -307,8 +307,8 @@ let internal maxCanvasShareExpiryDays = 30 /// Reads the `canvasShare` config section, falling back to `defaultCanvasShareConfig` for a missing /// section or field. Blank account/container values, a non-HTTPS/invalid `viewerBaseUrl`, or a /// `defaultExpiryDays` outside `1 .. maxCanvasShareExpiryDays` are treated as absent. User info, -/// query strings, and fragments are not valid on the base URL because published links must be clean -/// paths without embedded credentials. +/// non-root paths, query strings, and fragments are not valid on the base URL because published +/// links must use the viewer's root-mounted routes without embedded credentials. let internal readCanvasShareConfig () : CanvasShareConfig = withConfigDocument defaultCanvasShareConfig (fun root -> match root.TryGetProperty("canvasShare") with @@ -326,6 +326,7 @@ let internal readCanvasShareConfig () : CanvasShareConfig = when uri.Scheme = Uri.UriSchemeHttps && not (String.IsNullOrWhiteSpace(uri.Host)) && String.IsNullOrEmpty(uri.UserInfo) + && uri.AbsolutePath = "/" && String.IsNullOrEmpty(uri.Query) && String.IsNullOrEmpty(uri.Fragment) -> Some uri diff --git a/src/Tests/CanvasShareTests.fs b/src/Tests/CanvasShareTests.fs index 4166427a..35f26147 100644 --- a/src/Tests/CanvasShareTests.fs +++ b/src/Tests/CanvasShareTests.fs @@ -310,13 +310,20 @@ type CanvasShareConfigTests() = "a whitespace-only account name must not be published to")) [<Test>] - member _.``readCanvasShareConfig accepts a configurable HTTPS viewer URL``() = + member _.``readCanvasShareConfig rejects a viewer URL with a non-root path``() = withTempConfigDir "canvas-share-config" (fun dir -> seed dir """{ "canvasShare": { "viewerBaseUrl": " https://isolated-viewer.test:7443/base/ " } }""" + Assert.That(readCanvasShareConfig().ViewerBaseUrl, Is.EqualTo(None))) + + [<Test>] + member _.``readCanvasShareConfig accepts a configurable HTTPS viewer origin``() = + withTempConfigDir "canvas-share-config" (fun dir -> + seed dir + """{ "canvasShare": { "viewerBaseUrl": " https://isolated-viewer.test:7443/ " } }""" Assert.That( readCanvasShareConfig().ViewerBaseUrl, - Is.EqualTo(Some(Uri("https://isolated-viewer.test:7443/base/"))))) + Is.EqualTo(Some(Uri("https://isolated-viewer.test:7443/"))))) [<TestCase("")>] [<TestCase(" ")>] From 2e67a5d22ee1a6ba5d354ba7853055055e8ae70a Mon Sep 17 00:00:00 2001 From: Petr Pokorny <petr@innit.cz> Date: Tue, 18 Aug 2026 15:23:27 +0200 Subject: [PATCH 14/27] tm-canvas-safe-share-798 Block top-level content-route execution Serve active canvas HTML only for a fail-closed same-origin iframe navigation identified by exact single-value Fetch Metadata (Sec-Fetch-Site: same-origin, Sec-Fetch-Mode: navigate, Sec-Fetch-Dest: iframe). Direct, top-level, cross-site, partial, duplicated, and metadata-missing /content requests now receive the normal non-executable shell after their own independent path and expiry check, closing the containment escape where a top-level /content load could self-navigate to an external probe. Add Playwright containment coverage plus a hostile fixture that attempts cookie/storage reads, viewer and external fetches, remote image and form exfiltration, popups, frame self-navigation, and parent navigation; the external probe receives zero requests. --- docs/spec/canvas-sharing.md | 67 +- src/CanvasShareViewer/ViewerApplication.fs | 50 +- ...CanvasShareViewerContainmentTestHelpers.fs | 238 +++++ .../CanvasShareViewerContainmentTests.fs | 883 ++++++++++++++++++ src/Tests/CanvasShareViewerTests.fs | 221 ++++- src/Tests/Tests.fsproj | 2 + .../fixtures/canvas-share-viewer/hostile.html | 174 ++++ .../canvas-share-viewer/self-contained.html | 11 +- 8 files changed, 1604 insertions(+), 42 deletions(-) create mode 100644 src/Tests/CanvasShareViewerContainmentTestHelpers.fs create mode 100644 src/Tests/CanvasShareViewerContainmentTests.fs create mode 100644 src/Tests/fixtures/canvas-share-viewer/hostile.html diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index 6f431339..d6a4aec5 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -78,6 +78,16 @@ - The rendered page is a minimal shell that embeds the document itself from a separate content route inside a sandboxed iframe (see Technical Approach); the shell carries no document content and no privileged API of its own. +- The content route returns active HTML only for a fail-closed browser navigation whose Fetch + Metadata identifies a same-origin iframe (`Sec-Fetch-Site: same-origin`, + `Sec-Fetch-Mode: navigate`, and `Sec-Fetch-Dest: iframe`, each as one header value). A direct or + top-level navigation, a cross-site iframe, or a request with partial, duplicated, or missing + metadata receives the normal non-executable shell after its own path/expiry check. That shell's + sandboxed iframe then supplies the expected tuple in a Fetch-Metadata-capable browser, so a + recipient who opens `/content` directly still sees the document without ever executing it in the + top-level response. A browser or intermediary that omits Fetch Metadata from the iframe request + fails closed and does not render the active document; there is no headerless compatibility + bypass. ### Clipboard @@ -149,11 +159,12 @@ content route never trusts that the shell already checked. Otherwise the content route would be an unguarded bypass for an expired or malformed share whose URL the recipient still holds. - A matched route collapses path validity, Blob existence, and expiry into the single not-found - outcome: a valid shell path performs one exact `GetPropertiesAsync` call and a valid content path - performs one exact body read of `<prefix>/<filename>`, while malformed segments resolve to - not-found before any storage access, so untrusted dot segments never reach Blob URI construction. - Every not-found path then emits the identical response through the same application-level - ordering; only elapsed time may differ. + outcome. A valid shell path and a content request rejected by the Fetch Metadata gate each + perform one exact `GetPropertiesAsync` call; an accepted same-origin iframe content request + performs one exact body read of `<prefix>/<filename>`. Malformed segments resolve to not-found + before any storage access, so untrusted dot segments never reach Blob URI construction. Every + not-found path within its selected shell/content response policy emits the identical response + through the same application-level ordering; only elapsed time may differ. - An exception boundary registered before routing handles non-404 Azure Storage failures and `DefaultAzureCredential` failures independently of the ASP.NET Core environment. It logs only the exception type and available Azure status/error code, clears the route response, and emits @@ -162,12 +173,22 @@ `allow-forms`, `allow-popups`, and `allow-top-navigation`, so the embedded document's script can run but cannot read the viewer's cookies or storage, submit forms, open popups, or navigate the parent frame. +- The shell's `frame-src 'self'` is also part of containment: the sandbox permits the child to + navigate its own browsing context, while the ancestor's CSP blocks that self-navigation from + reaching an external origin. Chromium may replace the child with its local CSP error document + after such an attempt; no external request is sent. +- The content endpoint checks Fetch Metadata before choosing a storage lookup or response policy. + Only a request with the single-value same-origin/navigate/iframe tuple reaches the body-bearing + lookup and active-content response. Every other request fails closed to the shell policy and + properties-only lookup. A normal shell load therefore remains one properties lookup followed by + one iframe body read in supported browsers, while a direct `/content` load becomes that same + contained two-request sequence instead of executing the document at top level. - The content route's response carries a restrictive Content-Security-Policy that blocks outbound network/fetch/form targets, plus `X-Content-Type-Options: nosniff` and a strict - `Referrer-Policy`, so script that does run cannot exfiltrate over the network or leak the - referrer. Its CSP includes a `sandbox allow-scripts` directive, so the document stays sandboxed - even when an authenticated recipient opens the content URL directly at top level rather than - through the shell's iframe. + `Referrer-Policy`, so script that does run in the iframe cannot exfiltrate over those channels or + leak the referrer. Its CSP also includes `sandbox allow-scripts` as defense in depth for the + iframe's opaque-origin sandbox; top-level containment relies on the fail-closed Fetch Metadata + gate because a sandboxed top-level document can still navigate its own browsing context. - The viewer's managed identity is granted read-only (`Storage Blob Data Reader`) access scoped to the share container only -- it can read published blobs and their metadata, and nothing else. - Expiry is enforced synchronously on every request against the metadata the publisher wrote. The @@ -193,13 +214,14 @@ Response headers, by route: | Route | Headers | |---|---| -| Shell | `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store` | -| Content | `Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; sandbox allow-scripts`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store` | +| Shell, including a rejected `/content` navigation | `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store` | +| Active content (accepted same-origin iframe navigation only) | `Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; sandbox allow-scripts`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store` | | Dependency failure (either route) | HTTP 503 with an empty body and `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store` | `script-src`/`style-src` allow inline because a self-contained canvas doc *is* inline script and style. `unsafe-eval` preserves existing support for documents that use `eval` or `new Function`; -the opaque-origin sandbox and network-denying directives remain the security boundary. +the Fetch Metadata gate, opaque-origin iframe sandbox, and network-denying directives remain the +security boundary. `img-src data:`/`font-src data:`/`media-src data:` keep embedded assets working while denying the remote-URL fetch that would otherwise be a working exfiltration channel. @@ -259,9 +281,11 @@ remote-URL fetch that would otherwise be a working exfiltration channel. - The embedded document is contained by iframe sandboxing (script execution allowed; same-origin, forms, popups, and top-navigation denied) and a restrictive content CSP. This replaces the previous posture of serving canvas exports as unsandboxed, top-level active HTML. -- The content route's CSP and security headers apply regardless of authentication state, so even - script that does execute inside the sandbox cannot reach the network or leak referrer - information. +- The active-content CSP and security headers apply only after the content route accepts a + same-origin iframe navigation. Direct, top-level, cross-site, and metadata-missing requests get + the unframeable shell instead, preventing active script from using top-level self-navigation as a + network channel. Script that executes in the accepted sandbox still cannot read the viewer + origin, fetch over the network, or leak referrer information. - Missing, malformed, and expired share paths return an indistinguishable response -- same status, headers, and body -- to an authenticated caller; only response latency may differ, which is an accepted residual signal rather than a guarantee. Easy Auth rejects identities the tenant does @@ -286,7 +310,9 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | Re-check segments and expiry on the content route instead of trusting the shell | The recipient holds the URL, so the content route is directly reachable; a shell-only check would leave an expired share readable by editing the path. | | Use a properties-only Blob lookup for the shell and reserve the body read for the content route | The shell needs only existence and expiry metadata, so downloading and discarding the complete document there would double the transferred document bytes without strengthening validation. | | Accept case-insensitive `.html` suffixes and consecutive dots in one filename segment | Windows can surface documents such as `Status.HTML`, and `release..notes.html` is not traversal once `/` and `\` are forbidden. Preserving the original casing keeps the generated URL and exact Blob lookup aligned, while rejecting invalid names before publish prevents successful uploads with dead viewer links. | -| Put `sandbox allow-scripts` in the content route's CSP as well as on the iframe | The iframe attribute only covers the embedded case; the CSP directive also covers a signed-in recipient opening the content URL at top level, where the document would otherwise run on the viewer's authenticated origin. | +| Gate active content on exact same-origin iframe Fetch Metadata and serve the shell on mismatch | A CSP-sandboxed top-level document has an opaque origin but can still navigate its own browsing context. Browser-controlled Fetch Metadata distinguishes the intended sandboxed iframe navigation; failing closed to the normal shell keeps direct and metadata-missing loads contained without adding a second document-body read. | +| Keep `sandbox allow-scripts` in the active-content CSP as well as on the iframe | The response policy reinforces the iframe's opaque-origin, script-enabled boundary. It remains defense in depth rather than the top-level navigation boundary, which is enforced by the Fetch Metadata gate. | +| Keep `frame-src 'self'` on the shell | A sandboxed child cannot navigate its parent without permission but can navigate itself. The ancestor policy blocks an external self-navigation before the probe receives a request. | | Look the blob up by exact composed name, never by listing or prefix search | A share URL then reveals only its own document; no reachable code path can turn one link into an inventory of the container. | | Allow `unsafe-eval` only inside the contained document response | Shared canvases already support arbitrary inline JavaScript; preserving `eval`/`new Function` compatibility does not grant viewer-origin or network access because the sandbox and remaining CSP directives still deny both. | | Use `treemon.azurewebsites.net` rather than a custom domain or generated suffix | The Azure-provided hostname is short, TLS-enabled, requires no DNS ownership, and gives every shared document one stable origin for browser SSO. | @@ -330,15 +356,18 @@ remote-URL fetch that would otherwise be a working exfiltration channel. - A document is denied immediately once its metadata expiry has passed, before any lifecycle deletion runs. - The content route enforces the same checks as the shell: an expired or malformed share is denied - on the content route too, and a document opened directly at the content URL is still sandboxed. + on the content route too. A document opened directly at the content URL receives the normal + shell, runs only in its `sandbox="allow-scripts"` iframe, cannot navigate the top-level page, and + sends no request to an external probe. - A normal shell-plus-content page load performs one properties-only exact Blob lookup and one body-bearing exact read; the shell never downloads or buffers the document body. - Throwing storage and credential readers produce the same empty policy-headered 503 on shell and content routes in both Production and Development, with no framework diagnostic response. - Deleting or clearing a document's backing blob denies its link immediately (revocation). - A hostile fixture attempting cookie/storage access, same-origin fetches, form submission, - popups, top navigation, and network exfiltration all fail inside the sandboxed iframe and CSP, - while intended self-contained document scripting still works. + popups, parent/top navigation, frame self-navigation (`location`, `location.replace`, and + `_self`), and network exfiltration all fail inside the sandboxed iframe and CSP, while intended + self-contained document scripting still works. - Existing share UI/clipboard behavior -- AgentDoc-only button gating, `ShareState` lock and spinner, and clipboard-outcome banner routing -- continues to pass unchanged. - The actual secret detector is run against a clean viewer URL and does not flag it. diff --git a/src/CanvasShareViewer/ViewerApplication.fs b/src/CanvasShareViewer/ViewerApplication.fs index 612d4c68..9ded452d 100644 --- a/src/CanvasShareViewer/ViewerApplication.fs +++ b/src/CanvasShareViewer/ViewerApplication.fs @@ -56,6 +56,31 @@ module internal ViewerApplication = context.Response.Headers["Cache-Control"] <- "no-store" + let private requestHeaderEquals + (name: string) + (expected: string) + (context: HttpContext) + = + let values = context.Request.Headers[name] + + values.Count = 1 + && String.Equals( + values[0], + expected, + StringComparison.OrdinalIgnoreCase + ) + + let private isSameOriginIframeNavigation + (context: HttpContext) + = + [ + "Sec-Fetch-Site", "same-origin" + "Sec-Fetch-Mode", "navigate" + "Sec-Fetch-Dest", "iframe" + ] + |> List.forall (fun (name, expected) -> + requestHeaderEquals name expected context) + let rec private tryAzureFailureDetails (error: exn) = @@ -199,6 +224,24 @@ module internal ViewerApplication = context.Response.ContentLength <- 0L } + let private handleContent + reader + clock + (context: HttpContext) + = + if isSameOriginIframeNavigation context then + handle + (ShareLookup.resolveDocument reader clock) + ContentContentSecurityPolicy + writeContent + context + else + handle + (ShareLookup.resolveProperties reader clock) + ShellContentSecurityPolicy + writeShell + context + let create (builder: WebApplicationBuilder) (reader: BlobReader) @@ -218,10 +261,9 @@ module internal ViewerApplication = app.MapGet( ContentRoute, RequestDelegate( - handle - (ShareLookup.resolveDocument reader clock) - ContentContentSecurityPolicy - writeContent + handleContent + reader + clock ) ) |> ignore diff --git a/src/Tests/CanvasShareViewerContainmentTestHelpers.fs b/src/Tests/CanvasShareViewerContainmentTestHelpers.fs new file mode 100644 index 00000000..2d130fb8 --- /dev/null +++ b/src/Tests/CanvasShareViewerContainmentTestHelpers.fs @@ -0,0 +1,238 @@ +module Tests.CanvasShareViewerContainmentTestHelpers + +open System +open System.Collections.Concurrent +open System.Globalization +open System.IO +open System.Net +open System.Text +open System.Threading +open System.Threading.Tasks +open CanvasShareViewer +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Hosting +open Microsoft.AspNetCore.Http +open Tests.TestUtils + +let validPrefix = "0123456789ABCDEFGHIJKL" + +let private fixedNow = + DateTimeOffset( + 2030, + 1, + 1, + 0, + 0, + 0, + TimeSpan.Zero + ) + +let private liveMetadata = + Map [ + ShareExpiry.MetadataKey, + fixedNow + .AddHours(1.0) + .ToString("o", CultureInfo.InvariantCulture) + ] + +let private fixturePath (filename: string) = + Path.Combine( + __SOURCE_DIRECTORY__, + "fixtures", + "canvas-share-viewer", + filename + ) + +let private exportedFixture + (filename: string) + (replacements: (string * string) list) + : string + = + replacements + |> List.fold + (fun (content: string) (placeholder, value) -> + content.Replace( + placeholder, + value, + StringComparison.Ordinal + )) + (File.ReadAllText(fixturePath filename)) + |> Server.CanvasExport.buildStaticHtml + +let private blobDocument (content: string) : BlobDocument = + { Content = + content + |> Encoding.UTF8.GetBytes + |> ReadOnlyMemory<byte> + Metadata = liveMetadata } + +let private isolatedPorts () = + let rec reserve () = + let ports = getFreeTcpPorts 2 + + if ports |> List.contains 5000 then + reserve () + else + ports + + match reserve () with + | [ viewerPort; probePort ] -> + viewerPort, probePort + | _ -> + failwith "Expected exactly two isolated ports." + +type ContainmentHarness = + { ViewerBaseUrl: string + ProbeBaseUrl: string + ViewerPort: int + ProbePort: int + BlobRequests: ConcurrentQueue<string> + ProbeRequests: ConcurrentQueue<string> } + +let private startProbe + port + (requests: ConcurrentQueue<string>) + = + let builder = + WebApplication.CreateEmptyBuilder( + WebApplicationOptions() + ) + + builder.WebHost.UseKestrel(fun options -> + options.Listen(IPAddress.Loopback, port)) + |> ignore + + let app = builder.Build() + + let gif = + Convert.FromBase64String( + "R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" + ) + + app.Run( + RequestDelegate(fun context -> + task { + requests.Enqueue( + $"{context.Request.Method} http://127.0.0.1:{port}{context.Request.Path}{context.Request.QueryString}" + ) + + context.Response.Headers["Access-Control-Allow-Origin"] <- + "*" + + if + context.Request.Path.Value.Contains( + "image", + StringComparison.OrdinalIgnoreCase + ) + then + context.Response.ContentType <- "image/gif" + do! + context.Response.Body.WriteAsync( + gif, + context.RequestAborted + ) + else + context.Response.ContentType <- "text/plain" + do! context.Response.WriteAsync("probe-response") + }) + ) + + app + +let withContainmentHarness + (action: ContainmentHarness -> Task) + = + task { + let viewerPort, probePort = isolatedPorts () + let viewerBaseUrl = + $"http://127.0.0.1:{viewerPort}" + let probeBaseUrl = + $"http://127.0.0.1:{probePort}" + let probeRequests = + ConcurrentQueue<string>() + use probe = + startProbe probePort probeRequests + do! probe.StartAsync(CancellationToken.None) + + let hostile = + exportedFixture + "hostile.html" + [ + "{{PROBE_BASE_URL}}", probeBaseUrl + ] + + let benign = + exportedFixture "self-contained.html" [] + + let documents = + Map [ + $"{validPrefix}/hostile.html", + blobDocument hostile + $"{validPrefix}/self-contained.html", + blobDocument benign + ] + + let blobRequests = + ConcurrentQueue<string>() + + let reader = + { ReadPropertiesExact = + fun blobName _ -> + blobRequests.Enqueue( + $"PROPERTIES {blobName}" + ) + + documents + |> Map.tryFind blobName + |> Option.map _.Metadata + |> Task.FromResult + ReadExact = + fun blobName _ -> + blobRequests.Enqueue( + $"CONTENT {blobName}" + ) + + documents + |> Map.tryFind blobName + |> Task.FromResult } + + let builder = + WebApplication.CreateEmptyBuilder( + WebApplicationOptions() + ) + + builder.WebHost.UseKestrel(fun options -> + options.Listen( + IPAddress.Loopback, + viewerPort + )) + |> ignore + + use viewer = + ViewerApplication.create + builder + reader + (fun () -> fixedNow) + + do! viewer.StartAsync(CancellationToken.None) + + try + do! + action + { ViewerBaseUrl = viewerBaseUrl + ProbeBaseUrl = probeBaseUrl + ViewerPort = viewerPort + ProbePort = probePort + BlobRequests = blobRequests + ProbeRequests = probeRequests } + finally + viewer + .StopAsync(CancellationToken.None) + .GetAwaiter() + .GetResult() + + probe + .StopAsync(CancellationToken.None) + .GetAwaiter() + .GetResult() + } diff --git a/src/Tests/CanvasShareViewerContainmentTests.fs b/src/Tests/CanvasShareViewerContainmentTests.fs new file mode 100644 index 00000000..05532d83 --- /dev/null +++ b/src/Tests/CanvasShareViewerContainmentTests.fs @@ -0,0 +1,883 @@ +module Tests.CanvasShareViewerContainmentTests + +open System +open System.Collections.Concurrent +open System.IO +open System.Net +open System.Net.Http +open System.Text.Json +open System.Xml.Linq +open Microsoft.Playwright +open Microsoft.Playwright.NUnit +open NUnit.Framework +open Tests.CanvasShareViewerContainmentTestHelpers + +let private shellContentSecurityPolicy = + "default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'" + +let private contentContentSecurityPolicy = + "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; sandbox allow-scripts" + +let private getIframeContent + (client: HttpClient) + (url: string) + = + task { + use request = + new HttpRequestMessage(HttpMethod.Get, url) + + [ + "Sec-Fetch-Site", "same-origin" + "Sec-Fetch-Mode", "navigate" + "Sec-Fetch-Dest", "iframe" + ] + |> List.iter (fun (name: string, value: string) -> + request.Headers.TryAddWithoutValidation( + name, + value + ) + |> ignore) + + return! client.SendAsync(request) + } + +let private singleHeader + name + (response: HttpResponseMessage) + = + response.Headers.GetValues(name) + |> Seq.exactlyOne + +let private assertPolicy + expectedContentSecurityPolicy + (response: HttpResponseMessage) + = + Assert.Multiple(fun () -> + Assert.That( + singleHeader + "Content-Security-Policy" + response, + Is.EqualTo(expectedContentSecurityPolicy) + ) + + Assert.That( + singleHeader + "X-Content-Type-Options" + response, + Is.EqualTo("nosniff") + ) + + Assert.That( + singleHeader "Referrer-Policy" response, + Is.EqualTo("no-referrer") + ) + + Assert.That( + singleHeader "Cache-Control" response, + Is.EqualTo("no-store") + )) + +let private parseShell (html: string) = + html.Replace( + "<!doctype html>", + "", + StringComparison.OrdinalIgnoreCase + ) + |> XDocument.Parse + +let private artifactDirectory () = + Environment.GetEnvironmentVariable( + "CANVAS_VIEWER_VERIFICATION_ARTIFACT_DIR" + ) + |> Option.ofObj + |> Option.map _.Trim() + |> Option.filter (not << String.IsNullOrWhiteSpace) + |> Option.map (fun path -> + Directory.CreateDirectory(path) |> ignore + path) + +let private writeArtifact + (filename: string) + (content: string) + = + artifactDirectory () + |> Option.iter (fun directory -> + File.WriteAllText( + Path.Combine(directory, filename), + content + )) + +let private captureScreenshot + filename + (page: IPage) + = + task { + match artifactDirectory () with + | Some directory -> + let! _ = + page.ScreenshotAsync( + PageScreenshotOptions( + Path = Path.Combine( + directory, + filename + ), + FullPage = true + ) + ) + () + | None -> + () + } + +let private seedViewerOrigin + (page: IPage) + (harness: ContainmentHarness) + = + task { + let url = + $"{harness.ViewerBaseUrl}/c/{validPrefix}/self-contained.html" + + let! response = page.GotoAsync(url) + + Assert.That( + response.Status, + Is.EqualTo(200), + "the live viewer shell must be available before storage is seeded" + ) + + return! + page.EvaluateAsync<string>( + """() => { + document.cookie = 'viewer-auth=viewer-cookie-secret; path=/'; + localStorage.setItem('viewer-auth', 'viewer-storage-secret'); + return JSON.stringify({ + cookie: document.cookie, + storage: localStorage.getItem('viewer-auth') + }); + }""" + ) + } + +let private observePage + (page: IPage) + = + let requests = ConcurrentQueue<string>() + let responses = ConcurrentQueue<string>() + let requestFailures = ConcurrentQueue<string>() + let popups = ConcurrentQueue<string>() + let consoleMessages = ConcurrentQueue<string>() + let pageErrors = ConcurrentQueue<string>() + + page.Request.Add(fun request -> + requests.Enqueue( + $"{request.Method} {request.Url}" + )) + + page.Response.Add(fun response -> + responses.Enqueue( + $"{response.Status} {response.Url}" + )) + + page.RequestFailed.Add(fun request -> + requestFailures.Enqueue( + $"{request.Method} {request.Url} :: {request.Failure}" + )) + + page.Popup.Add(fun popup -> + popups.Enqueue(popup.Url)) + + page.Console.Add(fun message -> + consoleMessages.Enqueue( + $"{message.Type}: {message.Text}" + )) + + page.PageError.Add(fun error -> + pageErrors.Enqueue(string error)) + + requests, + responses, + requestFailures, + popups, + consoleMessages, + pageErrors + +let private escapeRequests + (harness: ContainmentHarness) + (requests: string array) + = + requests + |> Array.filter (fun request -> + request.Contains( + "/__viewer_escape__/", + StringComparison.Ordinal + ) + || request.Contains( + harness.ProbeBaseUrl, + StringComparison.Ordinal + )) + +let private assertHostileOutcomes + (resultJson: string) + = + use document = JsonDocument.Parse(resultJson) + let root = document.RootElement + let stringProperty (name: string) = + root.GetProperty(name).GetString() + + Assert.Multiple(fun () -> + Assert.That( + root.GetProperty("completed").GetBoolean(), + Is.True + ) + + Assert.That( + stringProperty "cookie", + Does.Not.Contain("viewer-cookie-secret"), + "the sandboxed document must not read viewer-origin cookies" + ) + + Assert.That( + stringProperty "localStorage", + Does.Not.Contain("viewer-storage-secret"), + "the sandboxed document must not read viewer-origin local storage" + ) + + Assert.That( + stringProperty "viewerFetch", + Does.StartWith("blocked:"), + "viewer-origin fetch must be blocked before a response is obtained" + ) + + Assert.That( + stringProperty "externalFetch", + Does.StartWith("blocked:"), + "external fetch must be blocked before a response is obtained" + ) + + Assert.That( + stringProperty "image", + Does.StartWith("blocked"), + "remote image exfiltration must be blocked" + ) + + Assert.That( + stringProperty "form", + Is.EqualTo("attempted") + ) + + Assert.That( + stringProperty "popup", + Is.EqualTo("blocked") + )) + +let private assertNoEscape + (harness: ContainmentHarness) + expectedPageUrl + expectedFrameUrl + (page: IPage) + (requests: ConcurrentQueue<string>) + (responses: ConcurrentQueue<string>) + (popups: ConcurrentQueue<string>) + = + let observedRequests = requests.ToArray() + let attemptedEscapeRequests = + escapeRequests harness observedRequests + let escapeResponses = + responses.ToArray() + |> escapeRequests harness + + let childFrameUrls = + page.Frames + |> Seq.filter (fun frame -> + not ( + Object.ReferenceEquals( + frame, + page.MainFrame + ) + )) + |> Seq.map _.Url + |> Array.ofSeq + + Assert.Multiple(fun () -> + Assert.That( + escapeResponses, + Is.Empty, + "no hostile network attempt may obtain a response" + ) + + Assert.That( + harness.ProbeRequests.ToArray(), + Is.Empty, + "the external probe server must receive no request" + ) + + Assert.That( + popups.ToArray(), + Is.Empty, + "the hostile fixture must not open a page" + ) + + Assert.That( + page.Context.Pages.Count, + Is.EqualTo(1), + "the browser context must still contain only the original page" + ) + + Assert.That( + page.Url, + Is.EqualTo(expectedPageUrl), + "the hostile fixture must not navigate the parent or top-level page" + ) + + Assert.That( + childFrameUrls, + Is.EqualTo([| expectedFrameUrl |]) + .Or.EqualTo( + [| "chrome-error://chromewebdata/" |] + ), + "self-navigation must remain on the document or Chromium's local CSP-block page" + ) + + Assert.That( + observedRequests, + Has.None.Contains(":5000"), + "verification must never touch the production port" + ) + + if attemptedEscapeRequests.Length > 0 then + TestContext.Out.WriteLine( + $"Browser-recorded escape attempts (response and probe assertions determine transport):{Environment.NewLine}{String.Join(Environment.NewLine, attemptedEscapeRequests)}" + )) + +type BrowserEvidence = + { Mode: string + ViewerUrl: string + ViewerPort: int + ProbePort: int + Seed: string + Result: string + Requests: string array + Responses: string array + RequestFailures: string array + ProbeRequests: string array + Popups: string array + ConsoleMessages: string array + PageErrors: string array + BlobRequests: string array } + +let private writeBrowserEvidence + filename + evidence + = + let json = + JsonSerializer.Serialize( + evidence, + JsonSerializerOptions(WriteIndented = true) + ) + + TestContext.Out.WriteLine(json) + writeArtifact filename json + +[<TestFixture>] +[<Category("E2E")>] +[<Category("ViewerContainment")>] +[<NonParallelizable>] +type CanvasShareViewerContainmentTests() = + inherit PageTest() + + [<Test>] + member _.``live viewer routes enforce the exact wire contract``() = + withContainmentHarness (fun harness -> + task { + use client = new HttpClient() + + use! hostileShell = + client.GetAsync( + $"{harness.ViewerBaseUrl}/c/{validPrefix}/hostile.html" + ) + + use! hostileContent = + getIframeContent + client + $"{harness.ViewerBaseUrl}/c/{validPrefix}/hostile.html/content" + + use! benignShell = + client.GetAsync( + $"{harness.ViewerBaseUrl}/c/{validPrefix}/self-contained.html" + ) + + use! benignContent = + getIframeContent + client + $"{harness.ViewerBaseUrl}/c/{validPrefix}/self-contained.html/content" + + let! shellHtml = + hostileShell.Content.ReadAsStringAsync() + + let shellDom = parseShell shellHtml + + let iframe = + shellDom.Descendants( + XName.Get("iframe") + ) + |> Seq.exactlyOne + + let sandbox = + iframe + .Attribute(XName.Get("sandbox")) + .Value + + [ + hostileShell + hostileContent + benignShell + benignContent + ] + |> List.iter (fun response -> + Assert.That( + response.StatusCode, + Is.EqualTo(HttpStatusCode.OK) + )) + + assertPolicy + shellContentSecurityPolicy + hostileShell + assertPolicy + contentContentSecurityPolicy + hostileContent + assertPolicy + shellContentSecurityPolicy + benignShell + assertPolicy + contentContentSecurityPolicy + benignContent + + Assert.Multiple(fun () -> + Assert.That( + sandbox, + Is.EqualTo("allow-scripts"), + "the iframe sandbox token set must be exact" + ) + + Assert.That( + harness.ViewerPort, + Is.Not.EqualTo(5000) + ) + + Assert.That( + harness.ProbePort, + Is.Not.EqualTo(5000) + )) + + let evidence = + JsonSerializer.Serialize( + {| viewerBaseUrl = + harness.ViewerBaseUrl + viewerPort = harness.ViewerPort + probePort = harness.ProbePort + statuses = + [| + int hostileShell.StatusCode + int hostileContent.StatusCode + int benignShell.StatusCode + int benignContent.StatusCode + |] + iframeSandbox = sandbox + shellCsp = + singleHeader + "Content-Security-Policy" + hostileShell + contentCsp = + singleHeader + "Content-Security-Policy" + hostileContent + xContentTypeOptions = + singleHeader + "X-Content-Type-Options" + hostileContent + referrerPolicy = + singleHeader + "Referrer-Policy" + hostileContent + cacheControl = + singleHeader + "Cache-Control" + hostileContent + blobRequests = + harness.BlobRequests.ToArray() |}, + JsonSerializerOptions( + WriteIndented = true + ) + ) + + TestContext.Out.WriteLine(evidence) + writeArtifact "wire-contract.json" evidence + }) + + [<Test>] + member this.``hostile fixture cannot escape the shell iframe``() = + withContainmentHarness (fun harness -> + task { + let! seed = + seedViewerOrigin this.Page harness + + let + (requests, + responses, + requestFailures, + popups, + consoleMessages, + pageErrors) = + observePage this.Page + + let url = + $"{harness.ViewerBaseUrl}/c/{validPrefix}/hostile.html" + + let! response = this.Page.GotoAsync(url) + + Assert.That( + response.Status, + Is.EqualTo(200) + ) + + let results = + this.Page + .FrameLocator("iframe") + .Locator("#hostile-results") + + do! + Assertions + .Expect(results) + .ToHaveAttributeAsync( + "data-complete", + "true", + LocatorAssertionsToHaveAttributeOptions( + Timeout = 10000.0f + ) + ) + + let! resultJson = results.TextContentAsync() + do! this.Page.WaitForTimeoutAsync(700.0f) + + do! + captureScreenshot + "hostile-iframe.png" + this.Page + + writeBrowserEvidence + "hostile-iframe.json" + { Mode = "shell iframe" + ViewerUrl = this.Page.Url + ViewerPort = harness.ViewerPort + ProbePort = harness.ProbePort + Seed = seed + Result = resultJson + Requests = requests.ToArray() + Responses = responses.ToArray() + RequestFailures = + requestFailures.ToArray() + ProbeRequests = + harness.ProbeRequests.ToArray() + Popups = popups.ToArray() + ConsoleMessages = + consoleMessages.ToArray() + PageErrors = pageErrors.ToArray() + BlobRequests = + harness.BlobRequests.ToArray() } + + assertHostileOutcomes resultJson + + assertNoEscape + harness + url + $"{url}/content" + this.Page + requests + responses + popups + }) + + [<Test>] + member this.``direct content navigation falls back to sandboxed shell containment``() = + withContainmentHarness (fun harness -> + task { + let! seed = + seedViewerOrigin this.Page harness + + let + (requests, + responses, + requestFailures, + popups, + consoleMessages, + pageErrors) = + observePage this.Page + + let url = + $"{harness.ViewerBaseUrl}/c/{validPrefix}/hostile.html/content" + + let! response = this.Page.GotoAsync(url) + + Assert.That( + response.Status, + Is.EqualTo(200) + ) + + let iframe = this.Page.Locator("iframe") + let! sandbox = + iframe.GetAttributeAsync("sandbox") + + let! topLevelHostileResults = + this.Page + .Locator("#hostile-results") + .CountAsync() + + let results = + this.Page + .FrameLocator("iframe") + .Locator("#hostile-results") + + do! + Assertions + .Expect(results) + .ToHaveAttributeAsync( + "data-complete", + "true", + LocatorAssertionsToHaveAttributeOptions( + Timeout = 10000.0f + ) + ) + + let! resultJson = results.TextContentAsync() + do! this.Page.WaitForTimeoutAsync(700.0f) + + Assert.Multiple(fun () -> + Assert.That( + sandbox, + Is.EqualTo("allow-scripts"), + "a top-level content URL must render the normal sandboxed shell" + ) + + Assert.That( + topLevelHostileResults, + Is.Zero, + "the active document must not be emitted into the top-level response" + ) + + Assert.That( + harness.BlobRequests.ToArray(), + Is.EqualTo( + [| + $"PROPERTIES {validPrefix}/self-contained.html" + $"CONTENT {validPrefix}/self-contained.html" + $"PROPERTIES {validPrefix}/hostile.html" + $"CONTENT {validPrefix}/hostile.html" + |] + ), + "each shell load must perform one properties lookup and one iframe body read" + )) + + do! + captureScreenshot + "hostile-direct.png" + this.Page + + writeBrowserEvidence + "hostile-direct.json" + { Mode = + "direct content route shell fallback" + ViewerUrl = this.Page.Url + ViewerPort = harness.ViewerPort + ProbePort = harness.ProbePort + Seed = seed + Result = resultJson + Requests = requests.ToArray() + Responses = responses.ToArray() + RequestFailures = + requestFailures.ToArray() + ProbeRequests = + harness.ProbeRequests.ToArray() + Popups = popups.ToArray() + ConsoleMessages = + consoleMessages.ToArray() + PageErrors = pageErrors.ToArray() + BlobRequests = + harness.BlobRequests.ToArray() } + + assertHostileOutcomes resultJson + + assertNoEscape + harness + url + url + this.Page + requests + responses + popups + }) + + [<Test>] + member this.``benign self-contained scripting remains interactive``() = + withContainmentHarness (fun harness -> + task { + let requests = ConcurrentQueue<string>() + + this.Page.Request.Add(fun request -> + requests.Enqueue( + $"{request.Method} {request.Url}" + )) + + let url = + $"{harness.ViewerBaseUrl}/c/{validPrefix}/self-contained.html" + + let! response = this.Page.GotoAsync(url) + + Assert.That( + response.Status, + Is.EqualTo(200) + ) + + let frame = + this.Page.FrameLocator("iframe") + + let status = + frame.Locator("#execution-status") + + do! + Assertions + .Expect(status) + .ToHaveAttributeAsync( + "data-inline", + "ran" + ) + + do! + Assertions + .Expect(status) + .ToHaveAttributeAsync( + "data-eval", + "ran" + ) + + do! + Assertions + .Expect(status) + .ToHaveAttributeAsync( + "data-dynamic-function", + "ran" + ) + + do! + Assertions + .Expect(status) + .ToHaveAttributeAsync( + "data-canvas-send", + "inert" + ) + + let! color = + status.EvaluateAsync<string>( + "element => getComputedStyle(element).color" + ) + + let image = + frame.Locator("#embedded-image") + + let! imageLoaded = + image.EvaluateAsync<bool>( + "element => element.complete && element.naturalWidth === 1 && element.naturalHeight === 1" + ) + + let details = + frame.Locator("#native-disclosure") + + Assert.That( + details.GetAttributeAsync("open") + .GetAwaiter() + .GetResult(), + Is.Null + ) + + do! + frame + .Locator( + "#native-disclosure summary" + ) + .ClickAsync() + + do! + Assertions + .Expect(details) + .ToHaveAttributeAsync("open", "") + + let observedRequests = requests.ToArray() + let! inlineExecution = + status.GetAttributeAsync("data-inline") + let! evalExecution = + status.GetAttributeAsync("data-eval") + let! newFunctionExecution = + status.GetAttributeAsync( + "data-dynamic-function" + ) + let! canvasSendOutcome = + status.GetAttributeAsync( + "data-canvas-send" + ) + let! disclosureOpen = + details.GetAttributeAsync("open") + + Assert.Multiple(fun () -> + Assert.That( + color, + Is.EqualTo("rgb(20, 90, 160)"), + "inline style must apply" + ) + + Assert.That( + imageLoaded, + Is.True, + "the data image must load" + ) + + Assert.That( + observedRequests, + Has.None.Contains(":5000") + ) + + Assert.That( + escapeRequests + harness + observedRequests, + Is.Empty + )) + + let evidence = + JsonSerializer.Serialize( + {| viewerUrl = this.Page.Url + viewerPort = harness.ViewerPort + probePort = harness.ProbePort + inlineExecution = inlineExecution + evalExecution = evalExecution + newFunctionExecution = + newFunctionExecution + canvasSendOutcome = + canvasSendOutcome + inlineColor = color + dataImageLoaded = imageLoaded + disclosureOpen = disclosureOpen + requests = observedRequests + blobRequests = + harness.BlobRequests.ToArray() |}, + JsonSerializerOptions( + WriteIndented = true + ) + ) + + TestContext.Out.WriteLine(evidence) + writeArtifact "benign.json" evidence + + do! + captureScreenshot + "benign.png" + this.Page + }) diff --git a/src/Tests/CanvasShareViewerTests.fs b/src/Tests/CanvasShareViewerTests.fs index df8a9b60..6ab4e5ee 100644 --- a/src/Tests/CanvasShareViewerTests.fs +++ b/src/Tests/CanvasShareViewerTests.fs @@ -214,6 +214,42 @@ let private withThrowingViewer let private await (work: Task<'value>) = work.GetAwaiter().GetResult() +let private iframeNavigationHeaders = + [ + "Sec-Fetch-Site", "same-origin" + "Sec-Fetch-Mode", "navigate" + "Sec-Fetch-Dest", "iframe" + ] + +let private sendGetWithHeaders + (headers: (string * string) list) + (client: HttpClient) + (url: string) + = + task { + use request = + new HttpRequestMessage(HttpMethod.Get, url) + + headers + |> List.iter (fun (name, value) -> + request.Headers.TryAddWithoutValidation( + name, + value + ) + |> ignore) + + return! client.SendAsync(request) + } + +let private getIframeContent + (client: HttpClient) + (url: string) + = + sendGetWithHeaders + iframeNavigationHeaders + client + url + let private headerPairs (headers: HttpHeaders) = headers |> Seq.filter (fun header -> @@ -285,15 +321,17 @@ type private ResponseSnapshot = Headers: (string * string list) list Body: byte array } -let private responseSnapshot +let private responseSnapshotWithHeaders + requestHeaders (client: HttpClient) (url: string) : ResponseSnapshot = use response: HttpResponseMessage = - client.GetAsync(url) |> await + sendGetWithHeaders requestHeaders client url + |> await - let headers = + let responseHeaders = Seq.append (headerPairs response.Headers) (headerPairs response.Content.Headers) @@ -301,9 +339,24 @@ let private responseSnapshot |> List.ofSeq { StatusCode = response.StatusCode - Headers = headers + Headers = responseHeaders Body = response.Content.ReadAsByteArrayAsync() |> await } +let private responseSnapshot + (client: HttpClient) + (url: string) + = + responseSnapshotWithHeaders [] client url + +let private iframeContentSnapshot + (client: HttpClient) + (url: string) + = + responseSnapshotWithHeaders + iframeNavigationHeaders + client + url + let private configuration values = values |> Seq.map (fun (key, value) -> @@ -668,10 +721,20 @@ type ViewerRouteTests() = (fun logs client baseUrl -> let snapshots = [ - $"{baseUrl}/c/{validPrefix}/report.html" - $"{baseUrl}/c/{validPrefix}/report.html/content" + ( + [], + $"{baseUrl}/c/{validPrefix}/report.html" + ) + ( + iframeNavigationHeaders, + $"{baseUrl}/c/{validPrefix}/report.html/content" + ) ] - |> List.map (responseSnapshot client) + |> List.map (fun (headers, url) -> + responseSnapshotWithHeaders + headers + client + url) let expected = snapshots |> List.head let errorLogs = @@ -810,9 +873,9 @@ type ViewerRouteTests() = |> Set.ofArray use content = - client.GetAsync( + getIframeContent + client $"{baseUrl}/c/{validPrefix}/{encodedFilename}/content" - ) |> await let contentBody = @@ -878,6 +941,105 @@ type ViewerRouteTests() = "the shell must read only properties and the content route must perform the only body read" ))) + [<Test>] + member _.``content route fails closed to the shell without exact iframe Fetch Metadata``() = + let blobName = $"{validPrefix}/report.html" + let secretMarker = "active-document-secret-marker" + + let documents = + Map [ + blobName, + document + $"<html><body>{secretMarker}</body></html>" + liveMetadata + ] + + let rejectedMetadata = + [ + "missing", [] + "top-level", + [ + "Sec-Fetch-Site", "none" + "Sec-Fetch-Mode", "navigate" + "Sec-Fetch-Dest", "document" + ] + "partial", + [ + "Sec-Fetch-Site", "same-origin" + "Sec-Fetch-Mode", "navigate" + ] + "duplicated", + [ + "Sec-Fetch-Site", "same-origin" + "Sec-Fetch-Site", "same-origin" + "Sec-Fetch-Mode", "navigate" + "Sec-Fetch-Dest", "iframe" + ] + "cross-site iframe", + [ + "Sec-Fetch-Site", "cross-site" + "Sec-Fetch-Mode", "navigate" + "Sec-Fetch-Dest", "iframe" + ] + ] + + withViewer documents now (fun fake client baseUrl -> + let shellUrl = + $"{baseUrl}/c/{validPrefix}/report.html" + + let contentUrl = $"{shellUrl}/content" + let shell = responseSnapshot client shellUrl + + let fallbacks = + rejectedMetadata + |> List.map (fun (label, headers) -> + label, + responseSnapshotWithHeaders + headers + client + contentUrl) + + let active = + iframeContentSnapshot client contentUrl + + Assert.Multiple(fun () -> + fallbacks + |> List.iter (fun (label, fallback) -> + Assert.That( + fallback, + Is.EqualTo(shell), + $"{label} content navigation must receive the normal non-executable shell" + ) + + Assert.That( + fallback.Body + |> Encoding.UTF8.GetString, + Does.Not.Contain(secretMarker) + )) + + Assert.That( + active.StatusCode, + Is.EqualTo(HttpStatusCode.OK) + ) + + Assert.That( + active.Body + |> Encoding.UTF8.GetString, + Does.Contain(secretMarker), + "only the exact same-origin iframe navigation may receive active HTML" + ) + + Assert.That( + fake.Requests(), + Is.EqualTo( + (List.replicate + (rejectedMetadata.Length + 1) + (PropertiesLookup blobName)) + @ [ ContentRead blobName ] + ), + "every fallback must validate expiry independently without reading the body" + ))) + [<Test>] member _.``shell and content emit their exact response policies``() = let blobName = $"{validPrefix}/report.html" @@ -898,9 +1060,9 @@ type ViewerRouteTests() = |> await use content = - client.GetAsync( + getIframeContent + client $"{baseUrl}/c/{validPrefix}/report.html/content" - ) |> await Assert.Multiple(fun () -> @@ -959,9 +1121,9 @@ type ViewerRouteTests() = |> await use content = - client.GetAsync( + getIframeContent + client $"{baseUrl}/c/{validPrefix}/{encodedFilename}/content" - ) |> await Assert.Multiple(fun () -> @@ -1053,9 +1215,9 @@ type ViewerRouteTests() = withViewer documents now (fun _ client baseUrl -> use response = - client.GetAsync( + getIframeContent + client $"{baseUrl}/c/{validPrefix}/self-contained.html/content" - ) |> await let actual = @@ -1148,21 +1310,25 @@ type ViewerRouteTests() = [ PropertiesLookup missingName ContentRead missingName + PropertiesLookup missingName ] expiredName, [ PropertiesLookup expiredName ContentRead expiredName + PropertiesLookup expiredName ] missingExpiryName, [ PropertiesLookup missingExpiryName ContentRead missingExpiryName + PropertiesLookup missingExpiryName ] badExpiryName, [ PropertiesLookup badExpiryName ContentRead badExpiryName + PropertiesLookup badExpiryName ] ] @@ -1173,16 +1339,19 @@ type ViewerRouteTests() = responseSnapshot client $"{baseUrl}/c/{path}", + iframeContentSnapshot + client + $"{baseUrl}/c/{path}/content", responseSnapshot client $"{baseUrl}/c/{path}/content") - let expectedShell, expectedContent = + let expectedShell, expectedContent, _ = snapshots |> List.head Assert.Multiple(fun () -> snapshots - |> List.iter (fun (shell, content) -> + |> List.iter (fun (shell, content, fallback) -> Assert.That( shell, Is.EqualTo(expectedShell) @@ -1193,6 +1362,12 @@ type ViewerRouteTests() = Is.EqualTo(expectedContent) ) + Assert.That( + fallback, + Is.EqualTo(expectedShell), + "rejected content requests must preserve the shell not-found response" + ) + Assert.That( shell.StatusCode, Is.EqualTo(HttpStatusCode.NotFound) @@ -1203,6 +1378,11 @@ type ViewerRouteTests() = Is.EqualTo(HttpStatusCode.NotFound) ) + Assert.That( + fallback.StatusCode, + Is.EqualTo(HttpStatusCode.NotFound) + ) + Assert.That( shell.Body, Is.Empty @@ -1211,6 +1391,11 @@ type ViewerRouteTests() = Assert.That( content.Body, Is.Empty + ) + + Assert.That( + fallback.Body, + Is.Empty )) Assert.That( @@ -1219,7 +1404,7 @@ type ViewerRouteTests() = cases |> List.collect snd ), - "malformed paths must skip storage while each valid path performs one properties lookup and one body read" + "malformed paths must skip storage while each valid path performs both independent properties checks and one body read" ))) [<Test>] diff --git a/src/Tests/Tests.fsproj b/src/Tests/Tests.fsproj index 83def662..d06c58eb 100644 --- a/src/Tests/Tests.fsproj +++ b/src/Tests/Tests.fsproj @@ -100,6 +100,8 @@ <Compile Include="CanvasExportTests.fs" /> <Compile Include="CanvasShareTests.fs" /> <Compile Include="CanvasShareViewerTests.fs" /> + <Compile Include="CanvasShareViewerContainmentTestHelpers.fs" /> + <Compile Include="CanvasShareViewerContainmentTests.fs" /> <Compile Include="SmokeTests.fs" /> </ItemGroup> diff --git a/src/Tests/fixtures/canvas-share-viewer/hostile.html b/src/Tests/fixtures/canvas-share-viewer/hostile.html new file mode 100644 index 00000000..f0cfc58e --- /dev/null +++ b/src/Tests/fixtures/canvas-share-viewer/hostile.html @@ -0,0 +1,174 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8" /> + <title>Hostile viewer containment fixture + + + +
starting
+ + + diff --git a/src/Tests/fixtures/canvas-share-viewer/self-contained.html b/src/Tests/fixtures/canvas-share-viewer/self-contained.html index a79f929e..32467c33 100644 --- a/src/Tests/fixtures/canvas-share-viewer/self-contained.html +++ b/src/Tests/fixtures/canvas-share-viewer/self-contained.html @@ -10,10 +10,19 @@
ready
embedded pixel +
+ Show native disclosure +

Disclosure content

+
From 06b51454071b03a4cb2b86d11b64c8807fd259c3 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Tue, 18 Aug 2026 16:37:16 +0200 Subject: [PATCH 15/27] tm-canvas-safe-share-39x Make viewer deployment automation work on Windows Fix the four failures verification tm-canvas-safe-share-szi found on PowerShell 7.6 / Azure CLI 2.84: pass the pipe-bearing Linux runtime through the CLI JSON-file configuration input so az.cmd cannot reinterpret it, read resource fields that the CLI nests under properties or renames to appServicePlanId, retry restricted-tenant registration mutations with the one unambiguous publisher-owned serviceManagementReference, and keep dotnet publish output out of the package path value. Add Deployment.Tests.ps1 regressions and run them in CI. --- .github/workflows/ci.yml | 4 +- docs/canvas-share-viewer-deployment.md | 16 +- docs/spec/canvas-sharing.md | 10 + .../canvas-share-viewer-deployment/Azure.ps1 | 177 +++++++++- .../canvas-share-viewer-deployment/Common.ps1 | 3 +- .../Deployment.Tests.ps1 | 321 ++++++++++++++++++ scripts/deploy-canvas-share-viewer.ps1 | 3 +- 7 files changed, 510 insertions(+), 24 deletions(-) create mode 100644 scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d471e30a..fa7ab206 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,9 @@ jobs: - name: Test canvas share deployment shell: pwsh - run: ./scripts/canvas-share-viewer-deployment/ViewerBlobAccess.Tests.ps1 + run: | + ./scripts/canvas-share-viewer-deployment/ViewerBlobAccess.Tests.ps1 + ./scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 - name: Test .NET run: dotnet test src/Tests/Tests.fsproj --filter "Category!=Local" --no-build --verbosity normal diff --git a/docs/canvas-share-viewer-deployment.md b/docs/canvas-share-viewer-deployment.md index 3a4fa8bb..f7064031 100644 --- a/docs/canvas-share-viewer-deployment.md +++ b/docs/canvas-share-viewer-deployment.md @@ -27,6 +27,11 @@ domain. allow creation or ownership of the dedicated app registration and its federated credential. The operator must also be able to list the viewer identity's role assignments throughout the subscription and inherited parent scopes and read their role definitions. +- If the tenant enforces `serviceManagementReference` on app-registration changes, the signed-in + Azure CLI user must own applications that expose exactly one distinct non-empty reference value. + The script discovers that value only after Entra returns the specific required-field error; it + never invents, prints, or asks for a service reference, and fails when publisher-owned state is + absent or ambiguous. - An existing storage account configured in the machine-level Treemon config. The container may be omitted to use `canvas-shared`; the deployment creates it through the ARM control plane when needed: @@ -92,7 +97,9 @@ The script is idempotent and is intended to be run a second time with the same v assignments discovered anywhere in the subscription or inherited from a parent scope. 3. Creates or reuses one uniquely named, secret-free, current-tenant `AzureADMyOrg` app registration and service principal. The registration accepts only the canonical App Service - callback. + callback. On a restricted tenant's specific `serviceManagementReference` error, creation or + update retries with the one unambiguous reference already carried by applications the delegated + publisher owns. 4. Adds a federated credential whose subject is the managed identity principal. Easy Auth uses the slot-sticky `OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID` setting and its `clientSecretSettingName` sentinel, so no client secret is created. @@ -102,9 +109,10 @@ The script is idempotent and is intended to be run a second time with the same v 6. Merges `expire-shared-canvas-docs` into the storage account's complete lifecycle policy while preserving unrelated rules. Deletion starts only after more than 31 days, beyond the 30-day maximum share lifetime. -7. Disables FTP and SCM basic publishing credentials, builds a ZIP locally, and deploys it with - `az webapp deploy`. Azure CLI therefore uses Microsoft Entra authentication rather than a - deployment credential. +7. Supplies the pipe-bearing Linux runtime through Azure CLI's JSON-file configuration input, + avoiding command reinterpretation by the Windows `az.cmd` launcher. It then disables FTP and SCM + basic publishing credentials, builds a ZIP locally, and deploys it with `az webapp deploy`. + Azure CLI therefore uses Microsoft Entra authentication rather than a deployment credential. 8. Verifies the resulting control-plane configuration and then atomically sets: ```json diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index d6a4aec5..9853ef75 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -252,6 +252,13 @@ remote-URL fetch that would otherwise be a working exfiltration channel. registration; it reads account/container from the machine-level `canvasShare` config and resolves the delegated publisher as the current Azure CLI user. The fixed B1 Linux plan and any new resource group use the storage account's Azure location. +- Azure CLI resource reads accept both flattened command output and fields nested under + `properties`, including the current `appServicePlanId` web-app field. The pipe-bearing Linux + runtime value is supplied through the CLI's JSON-file configuration path rather than as an + `az.cmd` argument. If Entra alone rejects an app-registration create or update because + `serviceManagementReference` is required, provisioning retries with the one distinct non-empty + reference on applications owned by the delegated publisher; zero or multiple values fail closed + rather than inventing or ambiguously selecting an organizational service reference. - `-ValidateOnly` performs subscription/tenant, configuration, existing-resource, viewer-identity RBAC, global-name, and local-publish checks without changing Azure or machine configuration. An apply run reconciles resources, merges the canvas rule into the account's complete lifecycle @@ -317,6 +324,8 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | Allow `unsafe-eval` only inside the contained document response | Shared canvases already support arbitrary inline JavaScript; preserving `eval`/`new Function` compatibility does not grant viewer-origin or network access because the sandbox and remaining CSP directives still deny both. | | Use `treemon.azurewebsites.net` rather than a custom domain or generated suffix | The Azure-provided hostname is short, TLS-enabled, requires no DNS ownership, and gives every shared document one stable origin for browser SSO. | | Derive storage and publisher deployment inputs from machine/Azure CLI state | The existing `canvasShare` account/container and current delegated publisher are already the publisher's source of truth. Requiring them again as script arguments would permit a viewer and publisher to be provisioned against different containers or identities. | +| Reuse one unambiguous publisher-owned `serviceManagementReference` only when Entra requires it | Restricted tenants reject registration mutations without their organizational service reference, while an arbitrary GUID can be invalid or misrepresent ownership. Conditional discovery keeps the normal path unchanged, adds no secret or deployment-name input, and fails closed when publisher-owned state cannot identify one value. | +| Pass the Linux runtime through Azure CLI's JSON-file configuration input | The runtime contains `|`, which the Windows `az.cmd` launcher can reinterpret as a command pipe even when PowerShell supplied it as one argument. A file preserves the exact value without platform-specific quoting or reliance on Azure CLI installation internals. | | Treat only a container-scoped RBAC assignment (or a descendant scope) as proof of viewer containment | Fully interpreting arbitrary Azure RBAC conditions would reproduce the authorization engine and could silently accept a broader grant. A conditioned assignment at an account, resource-group, subscription, or parent scope therefore fails closed; the operator must remove it or use a dedicated identity. | | Merge the lifecycle rule instead of replacing the account policy | Azure lifecycle policies are whole-document resources. Preserving unrelated rules avoids destructive drift when the storage account has other lifecycle-managed data. | | Share with the whole tenant instead of requiring enterprise-application assignment | Sharing is link-driven and ad hoc; a maintained assignment list would lock out the colleagues and guests a link is handed to, while the unguessable path, tenant sign-in, and expiry already bound exposure. | @@ -338,6 +347,7 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | `src/Client/CanvasPane.fs`, `CanvasState.fs`, `CanvasUpdate.fs`, `index.html` | Share button, `ShareState` phase machine, clipboard write and banner routing (unchanged) | | `src/CanvasShareViewer/` | New App Service viewer: shell route, content route, expiry check, sandbox/CSP, Easy Auth configuration | | `scripts/deploy-canvas-share-viewer.ps1` | Idempotent non-production Azure provisioning, secret-free Easy Auth, Entra-authenticated ZIP deployment, validation, and machine-config update | +| `scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1` | Windows/Azure CLI shape, packaging-output, clean/existing reconciliation, and restricted-tenant registration regressions | | `scripts/canvas-share-viewer-deployment/ViewerBlobAccess.ps1` | Fail-closed audit of the viewer identity's effective Blob-read data-plane assignments | | `scripts/canvas-share-lifecycle-policy.json` | Container-filtered deletion rule starting after 31 days | | `docs/canvas-share-viewer-deployment.md` | Local operator prerequisites, dry run, apply, and durable-resource guidance | diff --git a/scripts/canvas-share-viewer-deployment/Azure.ps1 b/scripts/canvas-share-viewer-deployment/Azure.ps1 index bf9cf264..bb35d20f 100644 --- a/scripts/canvas-share-viewer-deployment/Azure.ps1 +++ b/scripts/canvas-share-viewer-deployment/Azure.ps1 @@ -32,6 +32,48 @@ function Assert-RegistrationIsDedicated { } } +function Get-AzureResourcePropertyValue { + param( + [Parameter(Mandatory)][object] $Resource, + [Parameter(Mandatory)][string] $Name + ) + + $directProperty = $Resource.PSObject.Properties[$Name] + if ($null -ne $directProperty) { + return $directProperty.Value + } + + $propertiesProperty = $Resource.PSObject.Properties['properties'] + if ($null -eq $propertiesProperty -or $null -eq $propertiesProperty.Value) { + return $null + } + + $nestedProperty = $propertiesProperty.Value.PSObject.Properties[$Name] + if ($null -ne $nestedProperty) { + return $nestedProperty.Value + } + + $null +} + +function Get-WebAppPlanResourceId { + param([Parameter(Mandatory)][pscustomobject] $WebApp) + + $planResourceId = + [string] (Get-AzureResourcePropertyValue ` + -Resource $WebApp ` + -Name 'appServicePlanId') + + if ([string]::IsNullOrWhiteSpace($planResourceId)) { + $planResourceId = + [string] (Get-AzureResourcePropertyValue ` + -Resource $WebApp ` + -Name 'serverFarmId') + } + + $planResourceId +} + function Get-ExistingResources { param([Parameter(Mandatory)][string] $SubscriptionId) @@ -101,7 +143,10 @@ function Assert-ExistingResourceSafety { throw "Resource group '$ResourceGroup' is tagged as production. This automation is non-production only." } - if ($null -ne $Existing.Plan -and -not [bool] $Existing.Plan.reserved) { + if ($null -ne $Existing.Plan -and + -not [bool] (Get-AzureResourcePropertyValue ` + -Resource $Existing.Plan ` + -Name 'reserved')) { throw "Existing App Service plan '$Plan' is not a Linux plan." } @@ -119,7 +164,7 @@ function Assert-ExistingResourceSafety { } if (-not [string]::Equals( - [string] $Existing.WebApp.serverFarmId, + (Get-WebAppPlanResourceId -WebApp $Existing.WebApp), [string] $Existing.Plan.id, [StringComparison]::OrdinalIgnoreCase)) { throw "Existing app '$appName' does not use plan '$Plan'." @@ -372,7 +417,8 @@ function Ensure-WebApp { [AllowNull()][pscustomobject] $ExistingWebApp, [Parameter(Mandatory)][pscustomobject] $PlanResource, [Parameter(Mandatory)][pscustomobject] $ManagedIdentity, - [Parameter(Mandatory)][string] $SubscriptionId + [Parameter(Mandatory)][string] $SubscriptionId, + [Parameter(Mandatory)][string] $WorkingDirectory ) if ($null -eq $ExistingWebApp) { @@ -406,11 +452,16 @@ function Ensure-WebApp { '--https-only', 'true', '--subscription', $SubscriptionId) + $siteConfigurationPath = Join-Path $WorkingDirectory 'webapp-site-config.json' + Write-JsonFile ` + -Value ([ordered]@{ linuxFxVersion = 'DOTNETCORE|10.0' }) ` + -Path $siteConfigurationPath + Invoke-AzNone -Arguments @( 'webapp', 'config', 'set', '--name', $appName, '--resource-group', $ResourceGroup, - '--linux-fx-version', 'DOTNETCORE|10.0', + '--generic-configurations', "@$siteConfigurationPath", '--startup-file', 'dotnet CanvasShareViewer.dll', '--ftps-state', 'Disabled', '--http20-enabled', 'true', @@ -424,7 +475,7 @@ function Ensure-WebApp { '--subscription', $SubscriptionId) if (-not [string]::Equals( - [string] $webApp.serverFarmId, + (Get-WebAppPlanResourceId -WebApp $webApp), [string] $PlanResource.id, [StringComparison]::OrdinalIgnoreCase)) { throw "App '$appName' is not attached to plan '$Plan'." @@ -433,30 +484,122 @@ function Ensure-WebApp { $webApp } +function Test-ServiceManagementReferenceRequired { + param([Parameter(Mandatory)][Management.Automation.ErrorRecord] $ErrorRecord) + + $ErrorRecord.Exception.Message -match '(?i)ServiceManagementReference field is required' +} + +function Get-OwnedServiceManagementReference { + $ownedApplications = @( + Invoke-AzJson -Arguments @('ad', 'app', 'list', '--show-mine') + ) + $references = @( + $ownedApplications + | ForEach-Object { + [string] (Get-AzureResourcePropertyValue ` + -Resource $_ ` + -Name 'serviceManagementReference') + } + | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + | Sort-Object -Unique + ) + + if ($references.Count -ne 1) { + throw "The tenant requires serviceManagementReference for app registration changes, but the current Azure CLI user owns $($references.Count) distinct reference values. Exactly one is required for an unambiguous secret-free deployment." + } + + [string] $references[0] +} + +function New-ViewerAppRegistration { + param([AllowEmptyString()][string] $ServiceManagementReference) + + $referenceArguments = + if ([string]::IsNullOrWhiteSpace($ServiceManagementReference)) { + @() + } else { + @('--service-management-reference', $ServiceManagementReference) + } + + Invoke-AzJson -Arguments (@( + 'ad', 'app', 'create', + '--display-name', $Registration, + '--sign-in-audience', 'AzureADMyOrg', + '--web-redirect-uris', $callbackUrl + ) + $referenceArguments) +} + +function Update-ViewerAppRegistration { + param( + [Parameter(Mandatory)][string] $AppId, + [AllowEmptyString()][string] $ServiceManagementReference + ) + + $referenceArguments = + if ([string]::IsNullOrWhiteSpace($ServiceManagementReference)) { + @() + } else { + @('--service-management-reference', $ServiceManagementReference) + } + + Invoke-AzNone -Arguments (@( + 'ad', 'app', 'update', + '--id', $AppId, + '--sign-in-audience', 'AzureADMyOrg', + '--web-redirect-uris', $callbackUrl, + '--enable-access-token-issuance', 'false', + '--enable-id-token-issuance', 'false' + ) + $referenceArguments) +} + function Ensure-AppRegistration { param([AllowNull()][pscustomobject] $ExistingRegistration) + $serviceManagementReference = '' $appRegistration = if ($null -eq $ExistingRegistration) { Write-Step "Creating single-tenant Entra app registration '$Registration'" - Invoke-AzJson -Arguments @( - 'ad', 'app', 'create', - '--display-name', $Registration, - '--sign-in-audience', 'AzureADMyOrg', - '--web-redirect-uris', $callbackUrl) + + try { + New-ViewerAppRegistration -ServiceManagementReference '' + } catch { + if (-not (Test-ServiceManagementReferenceRequired -ErrorRecord $_)) { + throw + } + + $serviceManagementReference = Get-OwnedServiceManagementReference + New-ViewerAppRegistration ` + -ServiceManagementReference $serviceManagementReference + } } else { $ExistingRegistration } Assert-RegistrationIsDedicated -AppRegistration $appRegistration - Invoke-AzNone -Arguments @( - 'ad', 'app', 'update', - '--id', [string] $appRegistration.appId, - '--sign-in-audience', 'AzureADMyOrg', - '--web-redirect-uris', $callbackUrl, - '--enable-access-token-issuance', 'false', - '--enable-id-token-issuance', 'false') + if ([string]::IsNullOrWhiteSpace($serviceManagementReference)) { + $serviceManagementReference = + [string] (Get-AzureResourcePropertyValue ` + -Resource $appRegistration ` + -Name 'serviceManagementReference') + } + + try { + Update-ViewerAppRegistration ` + -AppId ([string] $appRegistration.appId) ` + -ServiceManagementReference $serviceManagementReference + } catch { + if (-not [string]::IsNullOrWhiteSpace($serviceManagementReference) -or + -not (Test-ServiceManagementReferenceRequired -ErrorRecord $_)) { + throw + } + + $serviceManagementReference = Get-OwnedServiceManagementReference + Update-ViewerAppRegistration ` + -AppId ([string] $appRegistration.appId) ` + -ServiceManagementReference $serviceManagementReference + } $servicePrincipals = @( Invoke-AzJson -Arguments @( diff --git a/scripts/canvas-share-viewer-deployment/Common.ps1 b/scripts/canvas-share-viewer-deployment/Common.ps1 index 8d396f8d..d1ec2f46 100644 --- a/scripts/canvas-share-viewer-deployment/Common.ps1 +++ b/scripts/canvas-share-viewer-deployment/Common.ps1 @@ -300,7 +300,8 @@ function New-ViewerPackage { --configuration Release ` --output $publishDirectory ` --nologo ` - --verbosity minimal + --verbosity minimal | + Out-Host if ($LASTEXITCODE -ne 0) { throw 'CanvasShareViewer publish failed.' diff --git a/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 b/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 new file mode 100644 index 00000000..7c69c4b1 --- /dev/null +++ b/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 @@ -0,0 +1,321 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$viewerProject = Join-Path $repoRoot 'src' 'CanvasShareViewer' 'CanvasShareViewer.fsproj' +$lifecyclePolicyPath = Join-Path $repoRoot 'scripts' 'canvas-share-lifecycle-policy.json' +$minimumLifecycleDays = 31 +$viewerBaseUrl = 'https://treemon.azurewebsites.net' + +. (Join-Path $PSScriptRoot 'Common.ps1') + +function Assert-Equal { + param( + [AllowNull()][object] $Actual, + [AllowNull()][object] $Expected, + [Parameter(Mandatory)][string] $Because + ) + + if ($Actual -ne $Expected) { + throw "Expected '$Expected' but got '$Actual': $Because" + } +} + +function Assert-True { + param( + [Parameter(Mandatory)][bool] $Condition, + [Parameter(Mandatory)][string] $Because + ) + + if (-not $Condition) { + throw "Expected true: $Because" + } +} + +function Invoke-TestCase { + param( + [Parameter(Mandatory)][string] $Name, + [Parameter(Mandatory)][scriptblock] $Body + ) + + & $Body + Write-Host "PASS: $Name" +} + +function dotnet { + $arguments = @($args) + $outputIndex = [Array]::IndexOf($arguments, '--output') + if ($outputIndex -lt 0 -or $outputIndex + 1 -ge $arguments.Count) { + throw 'Mock dotnet publish did not receive --output.' + } + + $publishDirectory = [string] $arguments[$outputIndex + 1] + New-Item -ItemType Directory -Path $publishDirectory -Force | Out-Null + [IO.File]::WriteAllText( + (Join-Path $publishDirectory 'CanvasShareViewer.dll'), + 'deployment fixture', + [Text.UTF8Encoding]::new($false)) + Write-Output 'mock dotnet publish output' + $global:LASTEXITCODE = 0 +} + +Invoke-TestCase 'viewer package returns only its ZIP path' { + $workingDirectory = + Join-Path ([IO.Path]::GetTempPath()) "treemon-deployment-test-$([Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $workingDirectory | Out-Null + + try { + $results = @(New-ViewerPackage -WorkingDirectory $workingDirectory) + + Assert-Equal ` + -Actual $results.Count ` + -Expected 1 ` + -Because 'dotnet publish output must not flow into the PackagePath value' + Assert-True ` + -Condition ($results[0] -is [string]) ` + -Because 'Deploy-Viewer requires one string PackagePath' + Assert-True ` + -Condition (Test-Path -LiteralPath $results[0] -PathType Leaf) ` + -Because 'the returned ZIP package must exist' + } finally { + Remove-Item -LiteralPath $workingDirectory -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Remove-Item Function:dotnet + +$appName = 'treemon' +$callbackUrl = 'https://treemon.azurewebsites.net/.auth/login/aad/callback' +$federatedCredentialName = 'treemon-easy-auth' +$managedIdentityAssertionSetting = 'OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID' +$readerRole = 'Storage Blob Data Reader' +$contributorRole = 'Storage Blob Data Contributor' +$ResourceGroup = 'viewer-rg' +$Plan = 'viewer-plan' +$Identity = 'viewer-identity' +$Registration = 'viewer-registration' + +. (Join-Path $PSScriptRoot 'Azure.ps1') + +$script:scenario = '' +$script:azNoneCalls = @() +$script:createCallCount = 0 +$script:serviceManagementReference = '11111111-2222-3333-4444-555555555555' +$script:planResourceId = + '/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/viewer-rg/providers/Microsoft.Web/serverfarms/viewer-plan' +$script:webAppResult = + [pscustomobject]@{ + appServicePlanId = $script:planResourceId + identity = $null + } +$script:registrationResult = + [pscustomobject]@{ + appId = '99999999-8888-7777-6666-555555555555' + displayName = $Registration + passwordCredentials = @() + serviceManagementReference = $script:serviceManagementReference + web = [pscustomobject]@{ + redirectUris = @($callbackUrl) + } + } + +function Write-Step { + param([Parameter(Mandatory)][string] $Message) +} + +function Invoke-AzNone { + param([Parameter(Mandatory)][string[]] $Arguments) + + $script:azNoneCalls += + [pscustomobject]@{ Arguments = @($Arguments) } +} + +function Invoke-AzJson { + param([Parameter(Mandatory)][string[]] $Arguments) + + $command = $Arguments[0..2] -join ' ' + + switch ($script:scenario) { + 'webapp' { + if ($command -eq 'webapp show --name') { + return $script:webAppResult + } + } + 'registration' { + switch ($command) { + 'ad app create' { + $script:createCallCount++ + + if ($Arguments -notcontains '--service-management-reference') { + throw 'ServiceManagementReference field is required for Create, but is missing in the request.' + } + + return $script:registrationResult + } + 'ad app list' { + if ($Arguments -notcontains '--show-mine') { + throw 'Service-management reference discovery must be limited to apps owned by the current user.' + } + + return @( + [pscustomobject]@{ + serviceManagementReference = $script:serviceManagementReference + } + ) + } + 'ad sp list' { + return @() + } + 'ad app show' { + return $script:registrationResult + } + } + } + } + + throw "Unexpected mocked Azure CLI command: $($Arguments -join ' ')" +} + +Invoke-TestCase 'Azure CLI resource shapes resolve without external projections' { + $nestedPlan = + [pscustomobject]@{ + properties = [pscustomobject]@{ + reserved = $true + } + } + $nestedWebApp = + [pscustomobject]@{ + properties = [pscustomobject]@{ + serverFarmId = $script:planResourceId + } + } + + Assert-Equal ` + -Actual (Get-AzureResourcePropertyValue -Resource $nestedPlan -Name 'reserved') ` + -Expected $true ` + -Because 'Azure CLI 2.84 nests the Linux-plan flag under properties' + Assert-Equal ` + -Actual (Get-WebAppPlanResourceId -WebApp $script:webAppResult) ` + -Expected $script:planResourceId ` + -Because 'current Azure CLI uses appServicePlanId' + Assert-Equal ` + -Actual (Get-WebAppPlanResourceId -WebApp $nestedWebApp) ` + -Expected $script:planResourceId ` + -Because 'ARM-shaped responses can use nested serverFarmId' +} + +Invoke-TestCase 'clean and existing web apps use file-backed runtime configuration' { + $script:scenario = 'webapp' + $script:azNoneCalls = @() + $workingDirectory = + Join-Path ([IO.Path]::GetTempPath()) "treemon-webapp-test-$([Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $workingDirectory | Out-Null + + try { + $planResource = [pscustomobject]@{ id = $script:planResourceId } + $managedIdentity = + [pscustomobject]@{ + id = '/subscriptions/test/resourceGroups/viewer-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/viewer-identity' + } + + Ensure-WebApp ` + -ExistingWebApp $null ` + -PlanResource $planResource ` + -ManagedIdentity $managedIdentity ` + -SubscriptionId 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' ` + -WorkingDirectory $workingDirectory | + Out-Null + Ensure-WebApp ` + -ExistingWebApp $script:webAppResult ` + -PlanResource $planResource ` + -ManagedIdentity $managedIdentity ` + -SubscriptionId 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' ` + -WorkingDirectory $workingDirectory | + Out-Null + + $createCalls = @( + $script:azNoneCalls + | Where-Object { ($_.Arguments[0..2] -join ' ') -eq 'webapp create --name' } + ) + $configurationCalls = @( + $script:azNoneCalls + | Where-Object { ($_.Arguments[0..2] -join ' ') -eq 'webapp config set' } + ) + + Assert-Equal ` + -Actual $createCalls.Count ` + -Expected 1 ` + -Because 'only the clean-state invocation creates the App Service' + Assert-Equal ` + -Actual $configurationCalls.Count ` + -Expected 2 ` + -Because 'both clean and existing state reconcile runtime configuration' + + foreach ($call in $configurationCalls) { + Assert-True ` + -Condition (-not ($call.Arguments | Where-Object { $_ -match '\|' })) ` + -Because 'az.cmd must never receive the pipe-bearing Linux runtime as an argument' + + $configurationIndex = + [Array]::IndexOf($call.Arguments, '--generic-configurations') + Assert-True ` + -Condition ($configurationIndex -ge 0) ` + -Because 'runtime configuration must use the Azure CLI JSON-file option' + + $configurationPath = + ([string] $call.Arguments[$configurationIndex + 1]).TrimStart('@') + $configuration = + Get-Content -LiteralPath $configurationPath -Raw | + ConvertFrom-Json + Assert-Equal ` + -Actual ([string] $configuration.linuxFxVersion) ` + -Expected 'DOTNETCORE|10.0' ` + -Because 'the file must retain the exact Linux runtime value' + } + } finally { + Remove-Item -LiteralPath $workingDirectory -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Invoke-TestCase 'restricted-tenant registration creation converges on existing state' { + $script:scenario = 'registration' + $script:azNoneCalls = @() + $script:createCallCount = 0 + + $created = Ensure-AppRegistration -ExistingRegistration $null + $existing = Ensure-AppRegistration -ExistingRegistration $created + + Assert-Equal ` + -Actual $script:createCallCount ` + -Expected 2 ` + -Because 'the clean path retries once with the owned tenant service reference' + Assert-Equal ` + -Actual ([string] $existing.appId) ` + -Expected ([string] $script:registrationResult.appId) ` + -Because 'the existing-state path reuses the same registration' + + $updateCalls = @( + $script:azNoneCalls + | Where-Object { ($_.Arguments[0..2] -join ' ') -eq 'ad app update' } + ) + Assert-Equal ` + -Actual $updateCalls.Count ` + -Expected 2 ` + -Because 'both clean and existing state reconcile the registration' + + foreach ($call in $updateCalls) { + $referenceIndex = + [Array]::IndexOf($call.Arguments, '--service-management-reference') + Assert-True ` + -Condition ($referenceIndex -ge 0) ` + -Because 'restricted-tenant updates must preserve serviceManagementReference' + Assert-Equal ` + -Actual ([string] $call.Arguments[$referenceIndex + 1]) ` + -Expected $script:serviceManagementReference ` + -Because 'registration mutations must use the one unambiguous owned reference' + } +} + +Write-Host 'Canvas share deployment regression tests passed.' diff --git a/scripts/deploy-canvas-share-viewer.ps1 b/scripts/deploy-canvas-share-viewer.ps1 index dcb70340..e723c532 100644 --- a/scripts/deploy-canvas-share-viewer.ps1 +++ b/scripts/deploy-canvas-share-viewer.ps1 @@ -108,7 +108,8 @@ try { -ExistingWebApp $existingResources.WebApp ` -PlanResource $planResource ` -ManagedIdentity $managedIdentity ` - -SubscriptionId $azureContext.SubscriptionId + -SubscriptionId $azureContext.SubscriptionId ` + -WorkingDirectory $workingDirectory $appRegistration = Ensure-AppRegistration ` -ExistingRegistration $existingResources.Registration From b215f84f21e838a5765b1a3b7f3dd2dd9d76e571 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Tue, 18 Aug 2026 17:30:47 +0200 Subject: [PATCH 16/27] tm-canvas-safe-share-arh Fix Easy Auth callback for tenant users Easy Auth's browser callback requests response_type=code id_token with response_mode=form_post, but the dedicated app registration disabled ID-token issuance, so the callback returned 401 for tenant users. Enable ID-token issuance while keeping browser access-token issuance disabled, re-read the registration during deployed-state validation to reject audience, redirect, or issuance drift, and cover the CLI update shape and both negative cases in the deployment regression tests. --- docs/canvas-share-viewer-deployment.md | 3 +- docs/spec/canvas-sharing.md | 10 ++- .../canvas-share-viewer-deployment/Azure.ps1 | 48 +++++++++++- .../Deployment.Tests.ps1 | 74 +++++++++++++++++++ 4 files changed, 131 insertions(+), 4 deletions(-) diff --git a/docs/canvas-share-viewer-deployment.md b/docs/canvas-share-viewer-deployment.md index f7064031..bc5c540c 100644 --- a/docs/canvas-share-viewer-deployment.md +++ b/docs/canvas-share-viewer-deployment.md @@ -99,7 +99,8 @@ The script is idempotent and is intended to be run a second time with the same v registration and service principal. The registration accepts only the canonical App Service callback. On a restricted tenant's specific `serviceManagementReference` error, creation or update retries with the one unambiguous reference already carried by applications the delegated - publisher owns. + publisher owns. It enables ID-token issuance for Easy Auth's `code id_token` form-post callback + while leaving browser access-token issuance disabled. 4. Adds a federated credential whose subject is the managed identity principal. Easy Auth uses the slot-sticky `OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID` setting and its `clientSecretSettingName` sentinel, so no client secret is created. diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index 9853ef75..92634389 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -149,6 +149,9 @@ application code. Assignment is not required on the enterprise application: every identity the tenant authenticates, including B2B guests, passes the gate. The registration authenticates to Easy Auth via a managed-identity federated credential rather than a long-lived client secret. + The registration enables ID-token issuance because Easy Auth's browser callback requests + `response_type=code id_token` with `response_mode=form_post`; browser access-token issuance + remains disabled. - Two routes divide responsibility: a shell route (`/c//`) validates the request and expiry through an exact Blob properties lookup and renders a minimal HTML page without downloading the document body; a content route @@ -272,7 +275,9 @@ remote-URL fetch that would otherwise be a working exfiltration channel. `OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID` sentinel; the slot-sticky app setting with that name contains the user-assigned identity's client ID. The registration's federated credential trusts that identity's principal ID with the tenant v2 issuer and `api://AzureADTokenExchange` - audience. No client secret or extra login scope is created. + audience. Provisioning and deployed-state validation require ID-token issuance for Easy Auth's + hybrid callback while keeping browser access-token issuance disabled. No client secret or extra + login scope is created. - The canonical App Service, identity, Entra configuration, RBAC grants, and lifecycle policy remain deployed after verification. Verification removes only its document fixtures and any auxiliary resources created solely to prove the permission boundary. @@ -313,6 +318,7 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | Sandbox the content iframe without `allow-same-origin` | Granting it would hand the embedded document the viewer's authenticated origin (cookies, Easy Auth session) even though it also has script execution. | | Enforce expiry in the viewer at request time rather than relying on Blob lifecycle deletion | Lifecycle deletion runs on a daily-ish schedule and is a backstop; relying on it alone would leave documents readable past their promised expiry. | | Prefer a managed-identity federated credential over an Easy Auth client secret | Avoids minting, storing, or rotating a long-lived secret for the viewer's app registration. | +| Enable registration ID-token issuance but not browser access-token issuance | App Service Easy Auth uses an OIDC hybrid `code id_token` form-post callback and rejects sign-in when the registration cannot issue that ID token; it redeems the code server-side through managed-identity federation, so browser access-token issuance remains unnecessary. | | Store expiry as blob metadata rather than a separate data store | Keeps the expiry attached to the artifact it governs, with no second store to keep in sync; it travels and disappears with the blob. | | Re-check segments and expiry on the content route instead of trusting the shell | The recipient holds the URL, so the content route is directly reachable; a shell-only check would leave an expired share readable by editing the path. | | Use a properties-only Blob lookup for the shell and reserve the body read for the content route | The shell needs only existence and expiry metadata, so downloading and discarding the complete document there would double the transferred document bytes without strengthening validation. | @@ -347,7 +353,7 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | `src/Client/CanvasPane.fs`, `CanvasState.fs`, `CanvasUpdate.fs`, `index.html` | Share button, `ShareState` phase machine, clipboard write and banner routing (unchanged) | | `src/CanvasShareViewer/` | New App Service viewer: shell route, content route, expiry check, sandbox/CSP, Easy Auth configuration | | `scripts/deploy-canvas-share-viewer.ps1` | Idempotent non-production Azure provisioning, secret-free Easy Auth, Entra-authenticated ZIP deployment, validation, and machine-config update | -| `scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1` | Windows/Azure CLI shape, packaging-output, clean/existing reconciliation, and restricted-tenant registration regressions | +| `scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1` | Windows/Azure CLI shape, packaging-output, reconciliation, restricted-tenant registration, and Easy Auth callback regressions | | `scripts/canvas-share-viewer-deployment/ViewerBlobAccess.ps1` | Fail-closed audit of the viewer identity's effective Blob-read data-plane assignments | | `scripts/canvas-share-lifecycle-policy.json` | Container-filtered deletion rule starting after 31 days | | `docs/canvas-share-viewer-deployment.md` | Local operator prerequisites, dry run, apply, and durable-resource guidance | diff --git a/scripts/canvas-share-viewer-deployment/Azure.ps1 b/scripts/canvas-share-viewer-deployment/Azure.ps1 index bb35d20f..d502e0ab 100644 --- a/scripts/canvas-share-viewer-deployment/Azure.ps1 +++ b/scripts/canvas-share-viewer-deployment/Azure.ps1 @@ -56,6 +56,46 @@ function Get-AzureResourcePropertyValue { $null } +function Assert-AppRegistrationAuthenticationFlow { + param([Parameter(Mandatory)][pscustomobject] $AppRegistration) + + Assert-RegistrationIsDedicated -AppRegistration $AppRegistration + + $redirectUris = @($AppRegistration.web.redirectUris) + $implicitGrantSettingsProperty = + $AppRegistration.web.PSObject.Properties['implicitGrantSettings'] + $implicitGrantSettings = + if ($null -eq $implicitGrantSettingsProperty) { + $null + } else { + $implicitGrantSettingsProperty.Value + } + $idTokenIssuanceEnabled = + if ($null -eq $implicitGrantSettings) { + $false + } else { + [bool] (Get-AzureResourcePropertyValue ` + -Resource $implicitGrantSettings ` + -Name 'enableIdTokenIssuance') + } + $accessTokenIssuanceEnabled = + if ($null -eq $implicitGrantSettings) { + $false + } else { + [bool] (Get-AzureResourcePropertyValue ` + -Resource $implicitGrantSettings ` + -Name 'enableAccessTokenIssuance') + } + + if ([string] $AppRegistration.signInAudience -cne 'AzureADMyOrg' -or + $redirectUris.Count -ne 1 -or + [string] $redirectUris[0] -cne $callbackUrl -or + -not $idTokenIssuanceEnabled -or + $accessTokenIssuanceEnabled) { + throw "Entra app registration '$Registration' is not configured for Easy Auth's single-tenant code/id_token callback." + } +} + function Get-WebAppPlanResourceId { param([Parameter(Mandatory)][pscustomobject] $WebApp) @@ -549,7 +589,7 @@ function Update-ViewerAppRegistration { '--sign-in-audience', 'AzureADMyOrg', '--web-redirect-uris', $callbackUrl, '--enable-access-token-issuance', 'false', - '--enable-id-token-issuance', 'false' + '--enable-id-token-issuance', 'true' ) + $referenceArguments) } @@ -1039,6 +1079,12 @@ function Assert-DeployedState { throw 'An Easy Auth client-secret setting is present.' } + $currentAppRegistration = Invoke-AzJson -Arguments @( + 'ad', 'app', 'show', + '--id', [string] $AppRegistration.appId) + Assert-AppRegistrationAuthenticationFlow ` + -AppRegistration $currentAppRegistration + $authSettings = Invoke-AzJson -Arguments @( 'rest', '--method', 'get', diff --git a/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 b/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 index 7c69c4b1..be41a3ba 100644 --- a/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 +++ b/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 @@ -114,10 +114,15 @@ $script:registrationResult = [pscustomobject]@{ appId = '99999999-8888-7777-6666-555555555555' displayName = $Registration + signInAudience = 'AzureADMyOrg' passwordCredentials = @() serviceManagementReference = $script:serviceManagementReference web = [pscustomobject]@{ redirectUris = @($callbackUrl) + implicitGrantSettings = [pscustomobject]@{ + enableAccessTokenIssuance = $false + enableIdTokenIssuance = $true + } } } @@ -315,7 +320,76 @@ Invoke-TestCase 'restricted-tenant registration creation converges on existing s -Actual ([string] $call.Arguments[$referenceIndex + 1]) ` -Expected $script:serviceManagementReference ` -Because 'registration mutations must use the one unambiguous owned reference' + + $accessTokenIndex = + [Array]::IndexOf($call.Arguments, '--enable-access-token-issuance') + Assert-True ` + -Condition ($accessTokenIndex -ge 0) ` + -Because 'registration updates must set browser access-token issuance explicitly' + Assert-Equal ` + -Actual ([string] $call.Arguments[$accessTokenIndex + 1]) ` + -Expected 'false' ` + -Because 'Easy Auth does not need browser-issued access tokens' + + $idTokenIndex = + [Array]::IndexOf($call.Arguments, '--enable-id-token-issuance') + Assert-True ` + -Condition ($idTokenIndex -ge 0) ` + -Because 'registration updates must set ID-token issuance explicitly' + Assert-Equal ` + -Actual ([string] $call.Arguments[$idTokenIndex + 1]) ` + -Expected 'true' ` + -Because 'Easy Auth requests code and id_token at its form-post callback' } } +Invoke-TestCase 'deployed registration requires ID tokens without browser access tokens' { + Assert-AppRegistrationAuthenticationFlow ` + -AppRegistration $script:registrationResult + + $invalidRegistration = + [pscustomobject]@{ + appId = $script:registrationResult.appId + displayName = $Registration + passwordCredentials = @() + signInAudience = 'AzureADMyOrg' + web = [pscustomobject]@{ + redirectUris = @($callbackUrl) + implicitGrantSettings = [pscustomobject]@{ + enableAccessTokenIssuance = $false + enableIdTokenIssuance = $false + } + } + } + $rejectedMissingIdToken = $false + + try { + Assert-AppRegistrationAuthenticationFlow ` + -AppRegistration $invalidRegistration + } catch { + $rejectedMissingIdToken = + $_.Exception.Message -match 'code/id_token callback' + } + + Assert-True ` + -Condition $rejectedMissingIdToken ` + -Because 'deployed-state verification must reject a registration that breaks the Easy Auth callback' + + $invalidRegistration.web.implicitGrantSettings.enableIdTokenIssuance = $true + $invalidRegistration.web.implicitGrantSettings.enableAccessTokenIssuance = $true + $rejectedBrowserAccessToken = $false + + try { + Assert-AppRegistrationAuthenticationFlow ` + -AppRegistration $invalidRegistration + } catch { + $rejectedBrowserAccessToken = + $_.Exception.Message -match 'code/id_token callback' + } + + Assert-True ` + -Condition $rejectedBrowserAccessToken ` + -Because 'deployed-state verification must keep browser access-token issuance disabled' +} + Write-Host 'Canvas share deployment regression tests passed.' From 177d23f82c84c29dc9f80c30876a2a1e73191bb2 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Thu, 20 Aug 2026 13:02:31 +0200 Subject: [PATCH 17/27] Guard canvas viewer deployment subscription --- docs/canvas-share-viewer-deployment.md | 24 +- docs/spec/canvas-sharing.md | 67 ++++ .../canvas-share-viewer-deployment/Azure.ps1 | 111 +++++- .../canvas-share-viewer-deployment/Common.ps1 | 8 + .../Deployment.Tests.ps1 | 323 ++++++++++++++++++ scripts/deploy-canvas-share-viewer.ps1 | 5 +- 6 files changed, 511 insertions(+), 27 deletions(-) diff --git a/docs/canvas-share-viewer-deployment.md b/docs/canvas-share-viewer-deployment.md index bc5c540c..0488c0df 100644 --- a/docs/canvas-share-viewer-deployment.md +++ b/docs/canvas-share-viewer-deployment.md @@ -39,6 +39,7 @@ domain. ```json { "canvasShare": { + "approvedSubscription": "", "accountName": "", "container": "canvas-shared", "defaultExpiryDays": 7 @@ -46,9 +47,12 @@ domain. } ``` -The script reads the storage account and container from `~/.treemon/config.json` (or -`$TREEMON_CONFIG_DIR/config.json`) and uses the current Azure CLI user as the publisher. Storage -and publisher identifiers are therefore not additional command-line inputs. +The script reads the approved subscription, storage account, and container from +`~/.treemon/config.json` (or `$TREEMON_CONFIG_DIR/config.json`) and uses the current Azure CLI user +as the publisher. The `-Subscription` argument and selected Azure CLI account must both exactly +match the machine-private approved value before the script performs any resource-provider or Entra +application operation. Exact subscription and tenant identifiers remain local and are never +committed. Storage and publisher identifiers are therefore not additional command-line inputs. ## Validate without changing Azure @@ -65,12 +69,14 @@ Run the read-only validation first: -ValidateOnly ``` -Validation checks the selected subscription and tenant, the storage configuration, any existing -resources, global availability of `treemon` when the app does not yet exist, the lifecycle-policy -invariant, and a local Release publish of the viewer. If the requested managed identity already -exists, validation also resolves its direct and inherited role assignments and fails when any -effective Blob-read data action is scoped outside the configured share container. Group-derived -assignments are included. It performs no Azure mutation and does not write machine configuration. +Validation first checks the requested and selected subscription against the machine-private +approved target. A missing or mismatched value fails before any resource-provider or Entra +application operation. It then checks the tenant, storage configuration, existing resources, +global availability of `treemon` when the app does not yet exist, lifecycle-policy invariant, and +a local Release publish of the viewer. If the requested managed identity already exists, validation +also resolves its direct and inherited role assignments and fails when any effective Blob-read +data action is scoped outside the configured share container. Group-derived assignments are +included. It performs no Azure mutation and does not write machine configuration. ## Provision and deploy diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index 92634389..eea687f9 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -114,6 +114,16 @@ - `defaultExpiryDays` remains 7 and `maxCanvasShareExpiryDays` is 30. The share container's Blob lifecycle policy deletes only after 31 days or more, so cleanup never removes a document the viewer would still have served. +- The same section also carries `approvedSubscription`, read only by the deployment script and + never by the running server. It is machine-private: it has no repository default, no + placeholder value, and its absence means deployment is unconfigured rather than unrestricted. + Its value may be a subscription name or ID; the script resolves it to a subscription ID and + compares IDs, so the two forms are interchangeable. +- The deployment script and the server both resolve the config through `TREEMON_CONFIG_DIR` when + it is set. Migration reconciliation and live verification use that isolation deliberately: they + run against a throwaway config seeded with `accountName`, `container`, and + `approvedSubscription` copied from the private machine config, so a run cannot add + `viewerBaseUrl` to -- or otherwise alter -- the configuration the production instance reads. - The viewer reads its own required, non-secret ASP.NET Core settings from `CanvasShareViewer:StorageAccountName` and `CanvasShareViewer:ShareContainer` (App Service environment names use `CanvasShareViewer__StorageAccountName` and @@ -234,6 +244,21 @@ remote-URL fetch that would otherwise be a working exfiltration channel. non-production resource group. Subscription, tenant, resource-group, plan, identity, and registration names are operator inputs; the App Service name is `treemon`, producing `https://treemon.azurewebsites.net`. +- Machine-private configuration identifies the one approved subscription. Before any + resource-provider or Entra application operation, provisioning requires the operator input and + selected Azure CLI account to match that configured target exactly and fails with no changes when + the value is absent, ambiguous, disabled, or mismatched. Exact subscription and tenant + identifiers never appear in tracked repository content. + The match is by resolved subscription ID -- the configured value, the `-Subscription` input, and + the selected CLI account must all resolve to the same enabled subscription -- so a name and an ID + naming the same subscription agree, while a raw-string near-miss cannot pass. Failure diagnostics + name the configuration key and the mismatch, never the values. + The guard is mandatory and independent of whatever restrictions the operator's environment + places on direct Azure CLI use. An environment-level control can only see the commands issued to + it, not the `az` child processes this script spawns, so the script proves its own target on every + run; the two are layers, and neither is removed or relaxed because the other exists. Every + resource-plane `az` invocation the script makes names its subscription explicitly rather than + inheriting the CLI's selected account. - Before first creation, provisioning checks global App Service name availability and fails clearly if `treemon` is no longer available. It never silently appends a random suffix because that would change the durable shared-link origin and its browser SSO session. @@ -281,6 +306,31 @@ remote-URL fetch that would otherwise be a working exfiltration channel. - The canonical App Service, identity, Entra configuration, RBAC grants, and lifecycle policy remain deployed after verification. Verification removes only its document fixtures and any auxiliary resources created solely to prove the permission boundary. +- A cross-subscription correction preserves the canonical hostname by moving the App Service and + its plan together rather than deleting and recreating the globally named app. The correction is + split by who can reach which subscription: automation only ever operates in the approved + subscription and tenant, so it prepares the destination (resource group, replacement + user-assigned identity, container-scoped grant) and hands the operator a checklist; the operator + performs the move itself in the Azure portal; automation then blocks until the operator confirms + and reconciles the retained tenant-scoped app registration, federated credential, container + RBAC, settings, and deployed state against the replacement identity. Automation never queries, + validates, or mutates anything in the source subscription and never carries a resource ID, + subscription, or tenant identifier belonging to it; the checklist identifies what to move by the + canonical App Service and plan names the repository already fixes. + Source-side leftovers -- the obsolete identity, its role assignments, any stand-in verification + storage, and the former resource groups -- stay in place until the move is independently + verified, so the operator can move the app and plan back while automation restores the previous + identity reference, credential subject, and settings. Removing them is a separate, explicitly + approved operator step, not part of the move. + The source app still holds the global name until it moves, so a pre-move `-ValidateOnly` run + against the destination subscription is expected to stop at the name-availability check. That + check is not relaxed for the correction: the operator's move is what places the app in the + destination, and reconciliation runs afterwards, when the app is already there and the check no + longer applies. +- Decommissioning the source-side leftovers is a manual operator activity in the portal. Automation + prepares an ordered checklist describing each target by resource type and role -- never by + resource ID, subscription, or tenant identifier -- and reviews whatever redacted evidence the + operator supplies afterwards. It deletes nothing, at any scope, in either subscription. - Feature development, deployment, and verification never run a production lifecycle command (`treemon.ps1 deploy`/`start`/`stop`/`restart`) and never bind to or otherwise disturb the production instance on port 5000. @@ -330,6 +380,11 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | Allow `unsafe-eval` only inside the contained document response | Shared canvases already support arbitrary inline JavaScript; preserving `eval`/`new Function` compatibility does not grant viewer-origin or network access because the sandbox and remaining CSP directives still deny both. | | Use `treemon.azurewebsites.net` rather than a custom domain or generated suffix | The Azure-provided hostname is short, TLS-enabled, requires no DNS ownership, and gives every shared document one stable origin for browser SSO. | | Derive storage and publisher deployment inputs from machine/Azure CLI state | The existing `canvasShare` account/container and current delegated publisher are already the publisher's source of truth. Requiring them again as script arguments would permit a viewer and publisher to be provisioned against different containers or identities. | +| Require an exact machine-private subscription allowlist match | Azure CLI's ambient default proves only what is selected, not whether that subscription is approved for this workload. A private source of truth keeps identifiers out of the repository and makes a mistaken shared-subscription deployment fail before any resource operation. | +| Keep the in-script guard even when the environment already restricts direct CLI use | A control that filters issued commands cannot observe the `az` child processes a deployment script starts, so it stops covering exactly the operations this feature automates. The script's own check is the only one present inside a run, and an outer restriction is never accepted as a reason to remove or weaken it. | +| Move the App Service and plan together when correcting subscription placement | An ARM move preserves the globally unique `treemon.azurewebsites.net` name; deleting and recreating the app would briefly release that name and make rollback dependent on reacquiring it. | +| Have the operator perform the move in the portal rather than automating it | Automation is confined to the approved subscription and cannot query or validate the source one, so it cannot issue the move at all. Splitting the correction into prepare / operator move / reconcile keeps the one irreversible step under direct human control and leaves automation with only operations it is allowed to perform. | +| Decommission by exact resource allowlist rather than broad scope | Shared subscriptions can contain unrelated workloads. Fresh inventory, drift checks, and individual resource-ID deletion make collateral changes falsifiable and leave ambiguous resources untouched. | | Reuse one unambiguous publisher-owned `serviceManagementReference` only when Entra requires it | Restricted tenants reject registration mutations without their organizational service reference, while an arbitrary GUID can be invalid or misrepresent ownership. Conditional discovery keeps the normal path unchanged, adds no secret or deployment-name input, and fails closed when publisher-owned state cannot identify one value. | | Pass the Linux runtime through Azure CLI's JSON-file configuration input | The runtime contains `|`, which the Windows `az.cmd` launcher can reinterpret as a command pipe even when PowerShell supplied it as one argument. A file preserves the exact value without platform-specific quoting or reliance on Azure CLI installation internals. | | Treat only a container-scoped RBAC assignment (or a descendant scope) as proof of viewer containment | Fully interpreting arbitrary Azure RBAC conditions would reproduce the authorization engine and could silently accept a broader grant. A conditioned assignment at an account, resource-group, subscription, or parent scope therefore fails closed; the operator must remove it or use a dedicated identity. | @@ -387,6 +442,18 @@ remote-URL fetch that would otherwise be a working exfiltration channel. - Existing share UI/clipboard behavior -- AgentDoc-only button gating, `ShareState` lock and spinner, and clipboard-outcome banner routing -- continues to pass unchanged. - The actual secret detector is run against a clean viewer URL and does not flag it. +- Deployment against a subscription other than the machine-private approved one exits non-zero + before any resource-provider or Entra application call, and its output contains no exact + subscription name or ID; the approved context proceeds normally. +- After the cross-subscription correction the App Service and its plan are in the approved + subscription, still answer on `https://treemon.azurewebsites.net`, and the authenticated share + lifecycle -- publish, view, expiry, revocation, containment, fixed 503 -- still passes end to + end when driven through an isolated `TREEMON_CONFIG_DIR` on a port other than 5000, leaving the + production configuration and instance untouched. +- Nothing in the approved subscription outside the prepared destination and the reconciled app + changed, and the correction's own command log shows no operation against the source + subscription and no resource ID belonging to it. Source-side state is attested by the operator, + not queried by automation. ## Related Specs diff --git a/scripts/canvas-share-viewer-deployment/Azure.ps1 b/scripts/canvas-share-viewer-deployment/Azure.ps1 index d502e0ab..ff646ec5 100644 --- a/scripts/canvas-share-viewer-deployment/Azure.ps1 +++ b/scripts/canvas-share-viewer-deployment/Azure.ps1 @@ -233,7 +233,8 @@ function Assert-ExistingResourceSafety { 'rest', '--method', 'post', '--uri', "/subscriptions/$SubscriptionId/providers/Microsoft.Web/checknameavailability?api-version=2023-12-01", - '--body', "@$availabilityBodyPath") + '--body', "@$availabilityBodyPath", + '--subscription', $SubscriptionId) } finally { Remove-Item -LiteralPath $availabilityBodyPath -Force -ErrorAction SilentlyContinue } @@ -248,35 +249,108 @@ function Assert-ExistingResourceSafety { } } +function Resolve-EnabledAzureSubscription { + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Value, + + [Parameter(Mandatory)] + [string] $Source + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { + throw "$Source is absent or blank." + } + + try { + $account = Invoke-AzJson -Arguments @( + 'account', 'show', + '--subscription', $Value) + } catch { + throw "$Source could not be resolved uniquely to an enabled Azure subscription." + } + + if ($null -eq $account -or $account -is [array]) { + throw "$Source could not be resolved uniquely to an enabled Azure subscription." + } + + $idProperty = $account.PSObject.Properties['id'] + $stateProperty = $account.PSObject.Properties['state'] + + if ($null -eq $idProperty -or + $null -eq $stateProperty -or + [string]::IsNullOrWhiteSpace([string] $idProperty.Value) -or + -not [string]::Equals( + [string] $stateProperty.Value, + 'Enabled', + [StringComparison]::OrdinalIgnoreCase)) { + throw "$Source could not be resolved uniquely to an enabled Azure subscription." + } + + $account +} + function Get-AzureContext { + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $ApprovedSubscription, + + [Parameter(Mandatory)] + [string] $RequestedSubscription, + + [Parameter(Mandatory)] + [string] $RequestedTenant + ) + + if ([string]::IsNullOrWhiteSpace($ApprovedSubscription)) { + throw 'Treemon machine configuration must set canvasShare.approvedSubscription before viewer deployment.' + } + $cloud = Invoke-AzJson -Arguments @('cloud', 'show') if ([string] $cloud.name -cne 'AzureCloud') { throw "The canonical azurewebsites.net deployment requires the AzureCloud environment. Current cloud: $($cloud.name)." } - $targetAccount = Invoke-AzJson -Arguments @( - 'account', 'show', - '--subscription', $Subscription) + $approvedAccount = Resolve-EnabledAzureSubscription ` + -Value $ApprovedSubscription ` + -Source 'canvasShare.approvedSubscription' + $requestedAccount = Resolve-EnabledAzureSubscription ` + -Value $RequestedSubscription ` + -Source 'The -Subscription input' + + if (-not [string]::Equals( + [string] $approvedAccount.id, + [string] $requestedAccount.id, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'Azure subscription mismatch: -Subscription must resolve to canvasShare.approvedSubscription.' + } + $currentAccount = Invoke-AzJson -Arguments @('account', 'show') + if ([string]::IsNullOrWhiteSpace([string] $currentAccount.id) -or + -not [string]::Equals( + [string] $currentAccount.state, + 'Enabled', + [StringComparison]::OrdinalIgnoreCase)) { + throw 'The selected Azure CLI account does not identify an enabled subscription.' + } + if (-not [string]::Equals( - [string] $targetAccount.id, + [string] $approvedAccount.id, [string] $currentAccount.id, [StringComparison]::OrdinalIgnoreCase)) { - throw "Azure CLI is not currently selecting the requested subscription. Run 'az account set --subscription ' and retry." + throw 'Azure subscription mismatch: the selected Azure CLI account must resolve to canvasShare.approvedSubscription.' } if (-not [string]::Equals( - [string] $targetAccount.tenantId, - $Tenant, + [string] $approvedAccount.tenantId, + $RequestedTenant, [StringComparison]::OrdinalIgnoreCase)) { throw 'The requested tenant does not own the selected subscription.' } - if ([string] $targetAccount.state -cne 'Enabled') { - throw 'The selected subscription is not enabled.' - } - if ([string] $currentAccount.user.type -cne 'user') { throw 'Sign in to Azure CLI with the delegated publisher user before running this script.' } @@ -284,8 +358,8 @@ function Get-AzureContext { $publisher = Invoke-AzJson -Arguments @('ad', 'signed-in-user', 'show') [pscustomobject]@{ - SubscriptionId = [string] $targetAccount.id - TenantId = [string] $targetAccount.tenantId + SubscriptionId = [string] $approvedAccount.id + TenantId = [string] $approvedAccount.tenantId PublisherObjectId = [string] $publisher.id } } @@ -853,7 +927,8 @@ function Assert-NoClientSecretConfiguration { 'rest', '--method', 'get', '--uri', "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Web/sites/$appName/config/authsettingsV2?api-version=2023-12-01", - '--query', 'properties.identityProviders.azureActiveDirectory.registration.clientSecretSettingName') + '--query', 'properties.identityProviders.azureActiveDirectory.registration.clientSecretSettingName', + '--subscription', $SubscriptionId) if (-not [string]::IsNullOrWhiteSpace([string] $configuredCredentialSetting) -and [string] $configuredCredentialSetting -cne $managedIdentityAssertionSetting) { @@ -945,7 +1020,8 @@ function Ensure-EasyAuth { 'rest', '--method', 'put', '--uri', "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Web/sites/$appName/config/authsettingsV2?api-version=2023-12-01", - '--body', "@$authSettingsPath") + '--body', "@$authSettingsPath", + '--subscription', $SubscriptionId) } function Disable-BasicPublishingCredentials { @@ -1088,7 +1164,8 @@ function Assert-DeployedState { $authSettings = Invoke-AzJson -Arguments @( 'rest', '--method', 'get', - '--uri', "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Web/sites/$appName/config/authsettingsV2?api-version=2023-12-01") + '--uri', "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Web/sites/$appName/config/authsettingsV2?api-version=2023-12-01", + '--subscription', $SubscriptionId) $azureAd = $authSettings.properties.identityProviders.azureActiveDirectory if (-not [bool] $authSettings.properties.platform.enabled -or diff --git a/scripts/canvas-share-viewer-deployment/Common.ps1 b/scripts/canvas-share-viewer-deployment/Common.ps1 index d1ec2f46..efd55456 100644 --- a/scripts/canvas-share-viewer-deployment/Common.ps1 +++ b/scripts/canvas-share-viewer-deployment/Common.ps1 @@ -163,12 +163,20 @@ function Read-TreemonCanvasShareConfig { 'canvas-shared' } + $approvedSubscription = + if ($canvasShare.Contains('approvedSubscription')) { + [string] $canvasShare['approvedSubscription'] + } else { + '' + } + [pscustomobject]@{ Path = $path Raw = $raw Root = $root AccountName = $accountName.Trim() Container = $container + ApprovedSubscription = $approvedSubscription.Trim() } } diff --git a/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 b/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 index be41a3ba..4885833f 100644 --- a/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 +++ b/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 @@ -101,6 +101,10 @@ $Registration = 'viewer-registration' $script:scenario = '' $script:azNoneCalls = @() +$script:azJsonCalls = @() +$script:subscriptionAccounts = @{} +$script:selectedAccount = $null +$script:publisherResult = $null $script:createCallCount = 0 $script:serviceManagementReference = '11111111-2222-3333-4444-555555555555' $script:planResourceId = @@ -140,6 +144,37 @@ function Invoke-AzNone { function Invoke-AzJson { param([Parameter(Mandatory)][string[]] $Arguments) + if ($script:scenario -eq 'azure-context') { + $script:azJsonCalls += + [pscustomobject]@{ Arguments = @($Arguments) } + $command = $Arguments[0..([Math]::Min(2, $Arguments.Count - 1))] -join ' ' + + switch ($command) { + 'cloud show' { + return [pscustomobject]@{ name = 'AzureCloud' } + } + 'account show --subscription' { + $subscriptionIndex = [Array]::IndexOf($Arguments, '--subscription') + $subscription = [string] $Arguments[$subscriptionIndex + 1] + + if (-not $script:subscriptionAccounts.ContainsKey($subscription)) { + throw 'Synthetic subscription is unavailable.' + } + + return $script:subscriptionAccounts[$subscription] + } + 'account show' { + return $script:selectedAccount + } + 'ad signed-in-user show' { + return $script:publisherResult + } + default { + throw 'Unexpected Azure CLI command in subscription-guard test.' + } + } + } + $command = $Arguments[0..2] -join ' ' switch ($script:scenario) { @@ -183,6 +218,294 @@ function Invoke-AzJson { throw "Unexpected mocked Azure CLI command: $($Arguments -join ' ')" } +function Reset-AzureContextMocks { + $script:scenario = 'azure-context' + $script:azJsonCalls = @() + $script:subscriptionAccounts = @{} + $script:selectedAccount = $null + $script:publisherResult = + [pscustomobject]@{ + id = 'dddddddd-dddd-dddd-dddd-dddddddddddd' + } +} + +function New-SyntheticAccount { + param( + [Parameter(Mandatory)][string] $Id, + [Parameter(Mandatory)][string] $TenantId + ) + + [pscustomobject]@{ + id = $Id + tenantId = $TenantId + state = 'Enabled' + user = [pscustomobject]@{ type = 'user' } + } +} + +function Assert-NoPrivilegedAzureCalls { + $privilegedCalls = + @( + $script:azJsonCalls + | Where-Object { + $_.Arguments[0] -notin @('cloud', 'account') + } + ) + + Assert-Equal ` + -Actual $privilegedCalls.Count ` + -Expected 0 ` + -Because 'the subscription guard must fail before any resource-provider or Entra call' +} + +function Assert-SubscriptionValuesRedacted { + param( + [Parameter(Mandatory)][string] $Message, + [Parameter(Mandatory)][string[]] $Values + ) + + $revealed = + @( + $Values + | Where-Object { + $Message.Contains($_, [StringComparison]::OrdinalIgnoreCase) + } + ) + + Assert-Equal ` + -Actual $revealed.Count ` + -Expected 0 ` + -Because 'subscription guard diagnostics must not reveal configured names or IDs' +} + +$approvedSubscription = 'approved-subscription' +$requestedSubscription = 'requested-subscription' +$approvedSubscriptionId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' +$otherSubscriptionId = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' +$tenantId = 'cccccccc-cccc-cccc-cccc-cccccccccccc' + +Invoke-TestCase 'requested subscription mismatch fails before privileged Azure calls' { + Reset-AzureContextMocks + $approvedAccount = + New-SyntheticAccount ` + -Id $approvedSubscriptionId ` + -TenantId $tenantId + $script:subscriptionAccounts[$approvedSubscription] = $approvedAccount + $script:subscriptionAccounts[$requestedSubscription] = + New-SyntheticAccount ` + -Id $otherSubscriptionId ` + -TenantId $tenantId + $script:selectedAccount = $approvedAccount + $message = '' + + try { + Get-AzureContext ` + -ApprovedSubscription $approvedSubscription ` + -RequestedSubscription $requestedSubscription ` + -RequestedTenant $tenantId | + Out-Null + } catch { + $message = $_.Exception.Message + } + + Assert-True ` + -Condition ($message -match 'mismatch' -and + $message -match 'canvasShare\.approvedSubscription') ` + -Because 'the requested-subscription failure must identify the protected config key and mismatch' + Assert-NoPrivilegedAzureCalls + Assert-SubscriptionValuesRedacted ` + -Message $message ` + -Values @( + $approvedSubscription, + $requestedSubscription, + $approvedSubscriptionId, + $otherSubscriptionId) +} + +Invoke-TestCase 'selected Azure account mismatch fails before privileged Azure calls' { + Reset-AzureContextMocks + $approvedAccount = + New-SyntheticAccount ` + -Id $approvedSubscriptionId ` + -TenantId $tenantId + $script:subscriptionAccounts[$approvedSubscription] = $approvedAccount + $script:subscriptionAccounts[$requestedSubscription] = $approvedAccount + $script:selectedAccount = + New-SyntheticAccount ` + -Id $otherSubscriptionId ` + -TenantId $tenantId + $message = '' + + try { + Get-AzureContext ` + -ApprovedSubscription $approvedSubscription ` + -RequestedSubscription $requestedSubscription ` + -RequestedTenant $tenantId | + Out-Null + } catch { + $message = $_.Exception.Message + } + + Assert-True ` + -Condition ($message -match 'mismatch' -and + $message -match 'canvasShare\.approvedSubscription') ` + -Because 'the selected-account failure must identify the protected config key and mismatch' + Assert-NoPrivilegedAzureCalls + Assert-SubscriptionValuesRedacted ` + -Message $message ` + -Values @( + $approvedSubscription, + $requestedSubscription, + $approvedSubscriptionId, + $otherSubscriptionId) +} + +Invoke-TestCase 'absent approved-subscription config fails before any Azure call' { + Reset-AzureContextMocks + $configDirectory = + Join-Path ([IO.Path]::GetTempPath()) "treemon-config-test-$([Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $configDirectory | Out-Null + $previousConfigDirectory = $env:TREEMON_CONFIG_DIR + + try { + $env:TREEMON_CONFIG_DIR = $configDirectory + [IO.File]::WriteAllText( + (Join-Path $configDirectory 'config.json'), + '{"canvasShare":{"accountName":"fixture-account","container":"fixture-container"}}', + [Text.UTF8Encoding]::new($false)) + $configuration = Read-TreemonCanvasShareConfig + $message = '' + + try { + Get-AzureContext ` + -ApprovedSubscription $configuration.ApprovedSubscription ` + -RequestedSubscription $requestedSubscription ` + -RequestedTenant $tenantId | + Out-Null + } catch { + $message = $_.Exception.Message + } + + Assert-True ` + -Condition ($message -match 'canvasShare\.approvedSubscription') ` + -Because 'an absent key must fail closed and identify the missing configuration' + Assert-Equal ` + -Actual $script:azJsonCalls.Count ` + -Expected 0 ` + -Because 'an absent key must stop before Azure CLI is invoked' + } finally { + $env:TREEMON_CONFIG_DIR = $previousConfigDirectory + Remove-Item -LiteralPath $configDirectory -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Invoke-TestCase 'approved subscription context proceeds to publisher lookup' { + Reset-AzureContextMocks + $approvedAccount = + New-SyntheticAccount ` + -Id $approvedSubscriptionId ` + -TenantId $tenantId + $script:subscriptionAccounts[$approvedSubscription] = $approvedAccount + $script:subscriptionAccounts[$requestedSubscription] = $approvedAccount + $script:selectedAccount = $approvedAccount + + $context = + Get-AzureContext ` + -ApprovedSubscription $approvedSubscription ` + -RequestedSubscription $requestedSubscription ` + -RequestedTenant $tenantId + + Assert-Equal ` + -Actual $context.SubscriptionId ` + -Expected $approvedSubscriptionId ` + -Because 'the approved context must return the resolved subscription ID' + Assert-Equal ` + -Actual $context.TenantId ` + -Expected $tenantId ` + -Because 'the approved context must retain the requested tenant' + $publisherCalls = + @( + $script:azJsonCalls + | Where-Object { + ($_.Arguments -join ' ') -ceq 'ad signed-in-user show' + } + ) + Assert-Equal ` + -Actual $publisherCalls.Count ` + -Expected 1 ` + -Because 'publisher lookup may proceed only after every subscription identity agrees' +} + +Invoke-TestCase 'every Azure resource-plane call selects its subscription explicitly' { + $resourceFamilies = + @( + 'appservice', + 'group', + 'identity', + 'resource', + 'role', + 'storage', + 'webapp' + ) + $unguardedCalls = + @( + 'Azure.ps1', 'ViewerBlobAccess.ps1' + | ForEach-Object { + $path = Join-Path $PSScriptRoot $_ + $tokens = $null + $parseErrors = $null + $ast = + [Management.Automation.Language.Parser]::ParseFile( + $path, + [ref] $tokens, + [ref] $parseErrors) + + Assert-Equal ` + -Actual $parseErrors.Count ` + -Expected 0 ` + -Because 'deployment helpers must parse before their Azure calls can be audited' + + $ast.FindAll( + { + param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -match '^(Invoke-AzJson|Try-AzJson|Invoke-AzNone)$' + }, + $true) + | Where-Object { + $literals = + @( + $_.FindAll( + { + param($node) + $node -is [Management.Automation.Language.StringConstantExpressionAst] + }, + $true) + | ForEach-Object Value + ) + $family = + if ($literals.Count -gt 1) { + $literals[1] + } else { + '' + } + + ($family -in $resourceFamilies -or + ($family -eq 'rest' -and + $_.Extent.Text.Contains( + '/subscriptions/', + [StringComparison]::OrdinalIgnoreCase))) -and + $literals -notcontains '--subscription' + } + } + ) + + Assert-Equal ` + -Actual $unguardedCalls.Count ` + -Expected 0 ` + -Because 'resource-plane az calls must never inherit the ambient Azure CLI subscription' +} + Invoke-TestCase 'Azure CLI resource shapes resolve without external projections' { $nestedPlan = [pscustomobject]@{ diff --git a/scripts/deploy-canvas-share-viewer.ps1 b/scripts/deploy-canvas-share-viewer.ps1 index e723c532..1c846303 100644 --- a/scripts/deploy-canvas-share-viewer.ps1 +++ b/scripts/deploy-canvas-share-viewer.ps1 @@ -55,7 +55,10 @@ $treemonConfig = Read-TreemonCanvasShareConfig $desiredLifecycleRule = Get-LifecycleRule -Container $treemonConfig.Container Write-Step 'Validating Azure subscription, tenant, and delegated publisher' -$azureContext = Get-AzureContext +$azureContext = Get-AzureContext ` + -ApprovedSubscription $treemonConfig.ApprovedSubscription ` + -RequestedSubscription $Subscription ` + -RequestedTenant $Tenant $storageAccount = Get-StorageAccount ` -AccountName $treemonConfig.AccountName ` -SubscriptionId $azureContext.SubscriptionId From 7e6932ec90a00cb4a3cece65edd66e7b0ab9c520 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Thu, 20 Aug 2026 15:05:55 +0200 Subject: [PATCH 18/27] tm-canvas-safe-share-p96 Fix F1 Azure.ps1:223 cross-subscription correction flow Add CrossSubscriptionCorrection.ps1 with mutually exclusive -PrepareCrossSubscriptionMove and -ReconcileCrossSubscriptionMove parameter sets on deploy-canvas-share-viewer.ps1. Preparation returns before ordinary app discovery, so it no longer aborts on the global App Service name-availability check while the source subscription still owns the canonical name. It reconciles only the destination resource group, replacement identity, and its container-scoped reader grant, then prints a redacted portal checklist. Reconciliation requires -ConfirmPortalMoveCompleted before any local tool or Azure call, resolves the moved app and plan in the approved destination only, and never checks global name availability. Ordinary creation and -ValidateOnly keep the unchanged global-name guard. Deployment.Tests.ps1 covers phase boundaries, the confirmation gate, subscription scoping, and checklist redaction. --- docs/canvas-share-viewer-deployment.md | 54 +++ docs/spec/canvas-sharing.md | 23 +- .../CrossSubscriptionCorrection.ps1 | 243 ++++++++++ .../Deployment.Tests.ps1 | 429 +++++++++++++++++- scripts/deploy-canvas-share-viewer.ps1 | 77 +++- 5 files changed, 806 insertions(+), 20 deletions(-) create mode 100644 scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1 diff --git a/docs/canvas-share-viewer-deployment.md b/docs/canvas-share-viewer-deployment.md index 0488c0df..925cfd47 100644 --- a/docs/canvas-share-viewer-deployment.md +++ b/docs/canvas-share-viewer-deployment.md @@ -78,6 +78,60 @@ also resolves its direct and inherited role assignments and fails when any effec data action is scoped outside the configured share container. Group-derived assignments are included. It performs no Azure mutation and does not write machine configuration. +## Correct a deployment from another subscription + +The correction workflow never accepts a source subscription, tenant, resource group, identity, or +resource ID. Automation operates only in the machine-approved destination; the operator performs +the App Service move in the Azure portal. + +First prepare the destination: + +```powershell +.\scripts\deploy-canvas-share-viewer.ps1 ` + -Subscription '' ` + -Tenant '' ` + -ResourceGroup '' ` + -Plan '' ` + -Identity '' ` + -Registration '' ` + -PrepareCrossSubscriptionMove +``` + +Preparation confirms that the configured storage account and private share container already +exist in the approved subscription. It creates or reconciles only the destination resource group, +replacement user-assigned identity, and that identity's container-scoped `Storage Blob Data +Reader` assignment. It does not check global availability of `treemon`, create or read the App +Service or plan, mutate the app registration, change storage configuration, build or deploy the +viewer, or write Treemon configuration. It finishes by printing a redacted portal checklist. + +Follow that checklist in the Azure portal: move the canonical `treemon` App Service and its App +Service plan together into the prepared destination. Leave source-side identities, role +assignments, storage, and resource groups in place for rollback. Do not substitute an ordinary +`-ValidateOnly` or apply run for preparation; before the portal move those modes correctly stop +because the existing app still owns the global name. + +After the portal reports success, use an isolated `TREEMON_CONFIG_DIR` seeded with the approved +subscription, storage account, and container from the private machine configuration, then run: + +```powershell +.\scripts\deploy-canvas-share-viewer.ps1 ` + -Subscription '' ` + -Tenant '' ` + -ResourceGroup '' ` + -Plan '' ` + -Identity '' ` + -Registration '' ` + -ReconcileCrossSubscriptionMove ` + -ConfirmPortalMoveCompleted +``` + +The confirmation switch is mandatory and is checked before local tools or Azure are consulted. +Reconciliation requires the moved canonical app, its plan, the prepared identity, and the retained +registration to exist. It replaces the app's identity attachment with the destination identity, +then reconciles federation, private-container RBAC, settings, lifecycle policy, package, Easy Auth, +and deployed state through approved-subscription-only calls. The isolated config receives the +canonical `viewerBaseUrl`; the production config remains unchanged. + ## Provision and deploy Remove `-ValidateOnly` to apply the same plan: diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index eea687f9..b56131c1 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -308,15 +308,18 @@ remote-URL fetch that would otherwise be a working exfiltration channel. auxiliary resources created solely to prove the permission boundary. - A cross-subscription correction preserves the canonical hostname by moving the App Service and its plan together rather than deleting and recreating the globally named app. The correction is - split by who can reach which subscription: automation only ever operates in the approved - subscription and tenant, so it prepares the destination (resource group, replacement - user-assigned identity, container-scoped grant) and hands the operator a checklist; the operator - performs the move itself in the Azure portal; automation then blocks until the operator confirms - and reconciles the retained tenant-scoped app registration, federated credential, container - RBAC, settings, and deployed state against the replacement identity. Automation never queries, - validates, or mutates anything in the source subscription and never carries a resource ID, - subscription, or tenant identifier belonging to it; the checklist identifies what to move by the - canonical App Service and plan names the repository already fixes. + split by who can reach which subscription. `-PrepareCrossSubscriptionMove` confirms the + configured private storage/container and reconciles only the approved destination resource + group, replacement user-assigned identity, and its container-scoped reader grant; it neither + checks the global app name nor reads or creates an App Service or plan. + `-ReconcileCrossSubscriptionMove` requires `-ConfirmPortalMoveCompleted` before any tool or Azure + call, resolves the moved app and plan only in the approved destination, replaces the app's + identity attachment with the prepared identity, and reconciles the retained tenant-scoped app + registration, federated credential, container RBAC, settings, package, and deployed state. + Between those modes the operator performs the move in the Azure portal using the redacted + checklist. Automation never queries, validates, or mutates anything in the source subscription + and never carries a resource ID, subscription, or tenant identifier belonging to it; the + checklist identifies what to move by the canonical App Service and plan names. Source-side leftovers -- the obsolete identity, its role assignments, any stand-in verification storage, and the former resource groups -- stay in place until the move is independently verified, so the operator can move the app and plan back while automation restores the previous @@ -384,6 +387,7 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | Keep the in-script guard even when the environment already restricts direct CLI use | A control that filters issued commands cannot observe the `az` child processes a deployment script starts, so it stops covering exactly the operations this feature automates. The script's own check is the only one present inside a run, and an outer restriction is never accepted as a reason to remove or weaken it. | | Move the App Service and plan together when correcting subscription placement | An ARM move preserves the globally unique `treemon.azurewebsites.net` name; deleting and recreating the app would briefly release that name and make rollback dependent on reacquiring it. | | Have the operator perform the move in the portal rather than automating it | Automation is confined to the approved subscription and cannot query or validate the source one, so it cannot issue the move at all. Splitting the correction into prepare / operator move / reconcile keeps the one irreversible step under direct human control and leaves automation with only operations it is allowed to perform. | +| Expose preparation and reconciliation as mutually exclusive deployment-script modes | Preparation must bypass the ordinary global-name guard without weakening it, while reconciliation must not begin from an unconfirmed portal handoff. Separate parameter sets and an explicit post-move confirmation make those safety boundaries visible at invocation time. | | Decommission by exact resource allowlist rather than broad scope | Shared subscriptions can contain unrelated workloads. Fresh inventory, drift checks, and individual resource-ID deletion make collateral changes falsifiable and leave ambiguous resources untouched. | | Reuse one unambiguous publisher-owned `serviceManagementReference` only when Entra requires it | Restricted tenants reject registration mutations without their organizational service reference, while an arbitrary GUID can be invalid or misrepresent ownership. Conditional discovery keeps the normal path unchanged, adds no secret or deployment-name input, and fails closed when publisher-owned state cannot identify one value. | | Pass the Linux runtime through Azure CLI's JSON-file configuration input | The runtime contains `|`, which the Windows `az.cmd` launcher can reinterpret as a command pipe even when PowerShell supplied it as one argument. A file preserves the exact value without platform-specific quoting or reliance on Azure CLI installation internals. | @@ -408,6 +412,7 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | `src/Client/CanvasPane.fs`, `CanvasState.fs`, `CanvasUpdate.fs`, `index.html` | Share button, `ShareState` phase machine, clipboard write and banner routing (unchanged) | | `src/CanvasShareViewer/` | New App Service viewer: shell route, content route, expiry check, sandbox/CSP, Easy Auth configuration | | `scripts/deploy-canvas-share-viewer.ps1` | Idempotent non-production Azure provisioning, secret-free Easy Auth, Entra-authenticated ZIP deployment, validation, and machine-config update | +| `scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1` | Approved-destination preparation, redacted portal handoff, post-move discovery, and replacement-identity attachment | | `scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1` | Windows/Azure CLI shape, packaging-output, reconciliation, restricted-tenant registration, and Easy Auth callback regressions | | `scripts/canvas-share-viewer-deployment/ViewerBlobAccess.ps1` | Fail-closed audit of the viewer identity's effective Blob-read data-plane assignments | | `scripts/canvas-share-lifecycle-policy.json` | Container-filtered deletion rule starting after 31 days | diff --git a/scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1 b/scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1 new file mode 100644 index 00000000..288d61be --- /dev/null +++ b/scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1 @@ -0,0 +1,243 @@ +function Assert-CrossSubscriptionCorrectionParameters { + param( + [switch] $ReconcileCrossSubscriptionMove, + [switch] $ConfirmPortalMoveCompleted + ) + + if ($ReconcileCrossSubscriptionMove -and -not $ConfirmPortalMoveCompleted) { + throw 'Post-move reconciliation requires -ConfirmPortalMoveCompleted after the operator has verified that the portal move succeeded.' + } +} + +function Assert-CorrectionResourceGroupSafety { + param([AllowNull()][pscustomobject] $ExistingGroup) + + if ($null -eq $ExistingGroup) { + return + } + + $environmentTag = + if ($null -ne $ExistingGroup.tags -and + $null -ne $ExistingGroup.tags.PSObject.Properties['environment']) { + [string] $ExistingGroup.tags.PSObject.Properties['environment'].Value + } else { + '' + } + + if ($environmentTag -match '^(?i:prod|production)$') { + throw "Resource group '$ResourceGroup' is tagged as production. This automation is non-production only." + } +} + +function Assert-ConfiguredPrivateShareContainer { + param( + [Parameter(Mandatory)][pscustomobject] $StorageAccount, + [Parameter(Mandatory)][string] $Container, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + if ([bool] (Get-AzureResourcePropertyValue ` + -Resource $StorageAccount ` + -Name 'allowBlobPublicAccess')) { + throw 'The configured storage account permits public Blob access. Destination preparation will not change storage configuration.' + } + + $existingContainer = Try-AzJson -Arguments @( + 'storage', 'container-rm', 'show', + '--storage-account', [string] $StorageAccount.id, + '--name', $Container, + '--subscription', $SubscriptionId) + + if ($null -eq $existingContainer) { + throw "Configured Blob container '$Container' was not found in the approved subscription. Destination preparation will not create it." + } + + $publicAccess = + [string] (Get-AzureResourcePropertyValue ` + -Resource $existingContainer ` + -Name 'publicAccess') + + if (-not [string]::IsNullOrWhiteSpace($publicAccess) -and + $publicAccess -notmatch '^(?i:none|off)$') { + throw "Configured Blob container '$Container' permits public access. Destination preparation will not change it." + } +} + +function Get-CrossSubscriptionPreparationResources { + param([Parameter(Mandatory)][string] $SubscriptionId) + + $group = Try-AzJson -Arguments @( + 'group', 'show', + '--name', $ResourceGroup, + '--subscription', $SubscriptionId) + $identityResource = + if ($null -eq $group) { + $null + } else { + Try-AzJson -Arguments @( + 'identity', 'show', + '--name', $Identity, + '--resource-group', $ResourceGroup, + '--subscription', $SubscriptionId) + } + + [pscustomobject]@{ + Group = $group + Identity = $identityResource + } +} + +function Get-CrossSubscriptionMoveDestinationResources { + param([Parameter(Mandatory)][string] $SubscriptionId) + + $preparationResources = + Get-CrossSubscriptionPreparationResources -SubscriptionId $SubscriptionId + $planResource = + if ($null -eq $preparationResources.Group) { + $null + } else { + Try-AzJson -Arguments @( + 'appservice', 'plan', 'show', + '--name', $Plan, + '--resource-group', $ResourceGroup, + '--subscription', $SubscriptionId) + } + $webApp = + if ($null -eq $preparationResources.Group) { + $null + } else { + Try-AzJson -Arguments @( + 'rest', + '--method', 'get', + '--uri', "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Web/sites/${appName}?api-version=2023-12-01", + '--query', '{id:id,name:name,defaultHostName:properties.defaultHostName,kind:kind,appServicePlanId:properties.serverFarmId,httpsOnly:properties.httpsOnly}', + '--subscription', $SubscriptionId) + } + + [pscustomobject]@{ + Group = $preparationResources.Group + Plan = $planResource + Identity = $preparationResources.Identity + WebApp = $webApp + Registration = Get-ExactAppRegistration + } +} + +function Assert-CrossSubscriptionMovedResources { + param( + [Parameter(Mandatory)][pscustomobject] $Existing, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + if ($null -eq $Existing.Group) { + throw 'The prepared destination resource group was not found in the approved subscription.' + } + + Assert-CorrectionResourceGroupSafety -ExistingGroup $Existing.Group + + if ($null -eq $Existing.Plan) { + throw "The moved App Service plan '$Plan' was not found in the prepared destination." + } + + if (-not [bool] (Get-AzureResourcePropertyValue ` + -Resource $Existing.Plan ` + -Name 'reserved')) { + throw "Moved App Service plan '$Plan' is not a Linux plan." + } + + if ($null -eq $Existing.Identity) { + throw "Replacement user-assigned identity '$Identity' was not found in the prepared destination." + } + + if ($null -eq $Existing.WebApp) { + throw "The canonical App Service '$appName' was not found in the prepared destination. Confirm the portal move before reconciliation." + } + + if ([string] $Existing.WebApp.defaultHostName -cne 'treemon.azurewebsites.net' -or + [string] $Existing.WebApp.kind -notmatch '(^|,)linux($|,)' -or + -not [string]::Equals( + (Get-WebAppPlanResourceId -WebApp $Existing.WebApp), + [string] $Existing.Plan.id, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'The moved App Service does not match the canonical hostname, Linux kind, and requested destination plan.' + } + + if ($null -eq $Existing.Registration) { + throw "The retained Entra app registration '$Registration' was not found." + } + + Assert-RegistrationIsDedicated -AppRegistration $Existing.Registration + Assert-NoClientSecretConfiguration -SubscriptionId $SubscriptionId +} + +function Set-CrossSubscriptionReplacementIdentity { + param( + [Parameter(Mandatory)][pscustomobject] $ManagedIdentity, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + Write-Step 'Replacing the moved App Service identity attachment' + Invoke-AzNone -Arguments @( + 'webapp', 'identity', 'assign', + '--name', $appName, + '--resource-group', $ResourceGroup, + '--identities', [string] $ManagedIdentity.id, + '--subscription', $SubscriptionId) +} + +function Write-CrossSubscriptionMoveChecklist { + Write-Host '' + Write-Host 'Destination preparation complete. No source-subscription operation was issued.' + Write-Host "1. In the Azure portal, move the canonical App Service '$appName' and App Service plan '$Plan' together." + Write-Host '2. Select the approved destination subscription and the prepared destination resource group used for this run.' + Write-Host '3. Leave source-side identities, role assignments, storage, and resource groups unchanged for rollback.' + Write-Host '4. Wait for the portal move to succeed, seed an isolated TREEMON_CONFIG_DIR, then rerun the same command with -ReconcileCrossSubscriptionMove -ConfirmPortalMoveCompleted.' + Write-Host 'The automation does not accept or print a source subscription, tenant, resource group, or resource ID.' +} + +function Invoke-CrossSubscriptionMovePreparation { + param( + [Parameter(Mandatory)][pscustomobject] $StorageAccount, + [Parameter(Mandatory)][string] $Container, + [Parameter(Mandatory)][string] $ContainerScope, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + Write-Step 'Confirming the configured private destination storage' + Assert-ConfiguredPrivateShareContainer ` + -StorageAccount $StorageAccount ` + -Container $Container ` + -SubscriptionId $SubscriptionId + + $existing = + Get-CrossSubscriptionPreparationResources -SubscriptionId $SubscriptionId + Assert-CorrectionResourceGroupSafety -ExistingGroup $existing.Group + + if ($null -ne $existing.Identity) { + Assert-ViewerBlobAccessIsContainerOnly ` + -PrincipalObjectId ([string] $existing.Identity.principalId) ` + -ContainerScope $ContainerScope ` + -SubscriptionId $SubscriptionId + } + + $group = Ensure-ResourceGroup ` + -ExistingGroup $existing.Group ` + -StorageAccount $StorageAccount ` + -SubscriptionId $SubscriptionId + $managedIdentity = Ensure-ManagedIdentity ` + -ExistingIdentity $existing.Identity ` + -Location ([string] $group.location) ` + -SubscriptionId $SubscriptionId + Ensure-RoleAssignment ` + -PrincipalObjectId ([string] $managedIdentity.principalId) ` + -PrincipalType ServicePrincipal ` + -Role $readerRole ` + -Scope $ContainerScope ` + -SubscriptionId $SubscriptionId + Assert-ViewerBlobAccessIsContainerOnly ` + -PrincipalObjectId ([string] $managedIdentity.principalId) ` + -ContainerScope $ContainerScope ` + -SubscriptionId $SubscriptionId + + Write-CrossSubscriptionMoveChecklist +} diff --git a/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 b/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 index 4885833f..75184509 100644 --- a/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 +++ b/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 @@ -98,17 +98,59 @@ $Identity = 'viewer-identity' $Registration = 'viewer-registration' . (Join-Path $PSScriptRoot 'Azure.ps1') +. (Join-Path $PSScriptRoot 'CrossSubscriptionCorrection.ps1') $script:scenario = '' $script:azNoneCalls = @() $script:azJsonCalls = @() +$script:azTryCalls = @() +$script:blobAccessAudits = @() $script:subscriptionAccounts = @{} $script:selectedAccount = $null $script:publisherResult = $null $script:createCallCount = 0 +$script:nameAvailabilityCallCount = 0 $script:serviceManagementReference = '11111111-2222-3333-4444-555555555555' $script:planResourceId = '/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/viewer-rg/providers/Microsoft.Web/serverfarms/viewer-plan' +$script:correctionSubscriptionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' +$script:correctionStorageAccount = + [pscustomobject]@{ + id = "/subscriptions/$script:correctionSubscriptionId/resourceGroups/storage-rg/providers/Microsoft.Storage/storageAccounts/fixturestorage" + name = 'fixturestorage' + location = 'westeurope' + resourceGroup = 'storage-rg' + allowBlobPublicAccess = $false + } +$script:correctionGroup = + [pscustomobject]@{ + id = "/subscriptions/$script:correctionSubscriptionId/resourceGroups/$ResourceGroup" + name = $ResourceGroup + location = 'westeurope' + tags = [pscustomobject]@{ environment = 'nonproduction' } + } +$script:correctionIdentity = + [pscustomobject]@{ + id = "/subscriptions/$script:correctionSubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.ManagedIdentity/userAssignedIdentities/$Identity" + name = $Identity + principalId = '22222222-2222-2222-2222-222222222222' + clientId = '33333333-3333-3333-3333-333333333333' + } +$script:correctionPlan = + [pscustomobject]@{ + id = $script:planResourceId + name = $Plan + reserved = $true + } +$script:correctionWebApp = + [pscustomobject]@{ + id = "/subscriptions/$script:correctionSubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Web/sites/$appName" + name = $appName + defaultHostName = 'treemon.azurewebsites.net' + kind = 'app,linux' + appServicePlanId = $script:planResourceId + httpsOnly = $true + } $script:webAppResult = [pscustomobject]@{ appServicePlanId = $script:planResourceId @@ -144,9 +186,10 @@ function Invoke-AzNone { function Invoke-AzJson { param([Parameter(Mandatory)][string[]] $Arguments) + $script:azJsonCalls += + [pscustomobject]@{ Arguments = @($Arguments) } + if ($script:scenario -eq 'azure-context') { - $script:azJsonCalls += - [pscustomobject]@{ Arguments = @($Arguments) } $command = $Arguments[0..([Math]::Min(2, $Arguments.Count - 1))] -join ' ' switch ($command) { @@ -175,6 +218,60 @@ function Invoke-AzJson { } } + if ($script:scenario -eq 'global-name-unavailable') { + if ($Arguments[0] -eq 'rest' -and + ($Arguments -join ' ') -match 'checknameavailability') { + $script:nameAvailabilityCallCount++ + return [pscustomobject]@{ nameAvailable = $false } + } + + throw 'Unexpected Azure CLI command in global-name-availability test.' + } + + if ($script:scenario -eq 'correction-preparation') { + if ($Arguments[0] -eq 'rest' -and + ($Arguments -join ' ') -match 'checknameavailability') { + $script:nameAvailabilityCallCount++ + return [pscustomobject]@{ nameAvailable = $false } + } + + $command = $Arguments[0..2] -join ' ' + + switch ($command) { + 'group show --name' { + return $script:correctionGroup + } + 'identity show --name' { + return $script:correctionIdentity + } + 'role assignment list' { + return @() + } + default { + throw 'Unexpected Azure CLI command in correction-preparation test.' + } + } + } + + if ($script:scenario -eq 'correction-destination') { + $command = $Arguments[0..2] -join ' ' + + switch ($command) { + 'ad app list' { + return @($script:registrationResult) + } + 'ad app show' { + return $script:registrationResult + } + 'webapp config appsettings' { + return @() + } + default { + throw 'Unexpected Azure CLI command in correction-destination test.' + } + } + } + $command = $Arguments[0..2] -join ' ' switch ($script:scenario) { @@ -218,6 +315,69 @@ function Invoke-AzJson { throw "Unexpected mocked Azure CLI command: $($Arguments -join ' ')" } +function Try-AzJson { + param([Parameter(Mandatory)][string[]] $Arguments) + + $script:azTryCalls += + [pscustomobject]@{ Arguments = @($Arguments) } + $command = $Arguments[0..2] -join ' ' + + if ($script:scenario -eq 'correction-preparation') { + switch ($command) { + 'storage container-rm show' { + return [pscustomobject]@{ publicAccess = 'None' } + } + 'group show --name' { + return $null + } + default { + throw 'Unexpected optional Azure CLI command in correction-preparation test.' + } + } + } + + if ($script:scenario -eq 'correction-destination') { + switch ($command) { + 'group show --name' { + return $script:correctionGroup + } + 'identity show --name' { + return $script:correctionIdentity + } + 'appservice plan show' { + return $script:correctionPlan + } + 'rest --method get' { + if (($Arguments -join ' ') -match '/sites/treemon\?api-version=') { + return $script:correctionWebApp + } + + return $null + } + default { + throw 'Unexpected optional Azure CLI command in correction-destination test.' + } + } + } + + throw "Unexpected mocked optional Azure CLI command: $($Arguments -join ' ')" +} + +function Assert-ViewerBlobAccessIsContainerOnly { + param( + [Parameter(Mandatory)][string] $PrincipalObjectId, + [Parameter(Mandatory)][string] $ContainerScope, + [Parameter(Mandatory)][string] $SubscriptionId + ) + + $script:blobAccessAudits += + [pscustomobject]@{ + PrincipalObjectId = $PrincipalObjectId + ContainerScope = $ContainerScope + SubscriptionId = $SubscriptionId + } +} + function Reset-AzureContextMocks { $script:scenario = 'azure-context' $script:azJsonCalls = @() @@ -229,6 +389,17 @@ function Reset-AzureContextMocks { } } +function Reset-CorrectionMocks { + param([Parameter(Mandatory)][string] $Scenario) + + $script:scenario = $Scenario + $script:azNoneCalls = @() + $script:azJsonCalls = @() + $script:azTryCalls = @() + $script:blobAccessAudits = @() + $script:nameAvailabilityCallCount = 0 +} + function New-SyntheticAccount { param( [Parameter(Mandatory)][string] $Id, @@ -436,6 +607,258 @@ Invoke-TestCase 'approved subscription context proceeds to publisher lookup' { -Because 'publisher lookup may proceed only after every subscription identity agrees' } +Invoke-TestCase 'pre-move preparation bypasses the unavailable global app name safely' { + Reset-CorrectionMocks -Scenario 'correction-preparation' + $container = 'fixture-container' + $containerScope = + "$($script:correctionStorageAccount.id)/blobServices/default/containers/$container" + $output = @( + & { + Invoke-CrossSubscriptionMovePreparation ` + -StorageAccount $script:correctionStorageAccount ` + -Container $container ` + -ContainerScope $containerScope ` + -SubscriptionId $script:correctionSubscriptionId + } 6>&1 + ) | ForEach-Object { [string] $_ } + $outputText = $output -join [Environment]::NewLine + $mutatingCommands = + @( + $script:azNoneCalls + | ForEach-Object { $_.Arguments[0..2] -join ' ' } + ) + + Assert-Equal ` + -Actual $script:nameAvailabilityCallCount ` + -Expected 0 ` + -Because 'pre-move preparation must not check or weaken global App Service name availability' + Assert-Equal ` + -Actual $script:azNoneCalls.Count ` + -Expected 3 ` + -Because 'preparation may create only the destination group, replacement identity, and reader grant' + Assert-True ` + -Condition ($mutatingCommands -contains 'group create --name') ` + -Because 'the approved destination resource group must be prepared' + Assert-True ` + -Condition ($mutatingCommands -contains 'identity create --name') ` + -Because 'the replacement user-assigned identity must be prepared' + Assert-True ` + -Condition ($mutatingCommands -contains 'role assignment create') ` + -Because 'the replacement identity must receive its container-scoped reader grant' + Assert-True ` + -Condition (-not ($mutatingCommands | Where-Object { + $_ -match '^(appservice|storage|webapp|ad)\b' + })) ` + -Because 'preparation must not mutate the plan, app, storage, or Entra registration' + $wrongSubscriptionCalls = + @( + @($script:azJsonCalls) + @($script:azTryCalls) + @($script:azNoneCalls) + | Where-Object { + $subscriptionIndex = + [Array]::IndexOf($_.Arguments, '--subscription') + $subscriptionIndex -ge 0 -and + [string] $_.Arguments[$subscriptionIndex + 1] -cne + $script:correctionSubscriptionId + } + ) + Assert-Equal ` + -Actual $wrongSubscriptionCalls.Count ` + -Expected 0 ` + -Because 'preparation must issue every resource call only to the approved destination' + + $roleAssignmentCall = + @( + $script:azNoneCalls + | Where-Object { + ($_.Arguments[0..2] -join ' ') -eq 'role assignment create' + } + )[0] + Assert-True ` + -Condition ($roleAssignmentCall.Arguments -contains $readerRole -and + $roleAssignmentCall.Arguments -notcontains $contributorRole) ` + -Because 'preparation grants only Storage Blob Data Reader to the replacement identity' + Assert-Equal ` + -Actual $script:blobAccessAudits.Count ` + -Expected 1 ` + -Because 'the newly prepared identity must be audited after its reader grant' + Assert-True ` + -Condition ($outputText -match 'Destination preparation complete' -and + $outputText -match 'ConfirmPortalMoveCompleted') ` + -Because 'preparation must emit the portal handoff and the explicit reconciliation command' + Assert-True ` + -Condition ($outputText -notmatch '(?i)/subscriptions/|tenant-id|source-rg|source-identity|[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}') ` + -Because 'the emitted checklist must not contain source subscription, tenant, group, identity, or resource IDs' + + $entrypoint = + [IO.File]::ReadAllText( + (Join-Path (Split-Path -Parent $PSScriptRoot) 'deploy-canvas-share-viewer.ps1')) + $preparationIndex = + $entrypoint.IndexOf( + 'Invoke-CrossSubscriptionMovePreparation', + [StringComparison]::Ordinal) + $preparationReturnIndex = + $entrypoint.IndexOf( + 'return', + $preparationIndex, + [StringComparison]::Ordinal) + $ordinaryDiscoveryIndex = + $entrypoint.IndexOf( + '$existingResources =', + $preparationIndex, + [StringComparison]::Ordinal) + Assert-True ` + -Condition ($preparationIndex -ge 0 -and + $preparationReturnIndex -gt $preparationIndex -and + $preparationReturnIndex -lt $ordinaryDiscoveryIndex) ` + -Because 'the entry point must return from preparation before ordinary app discovery and its global-name guard' +} + +Invoke-TestCase 'ordinary creation and ValidateOnly retain the global-name failure' { + Reset-CorrectionMocks -Scenario 'global-name-unavailable' + $existing = + [pscustomobject]@{ + Group = $null + Plan = $null + Identity = $null + WebApp = $null + Registration = $null + } + $messages = + @( + 'ordinary apply', 'ordinary ValidateOnly' + | ForEach-Object { + try { + Assert-ExistingResourceSafety ` + -Existing $existing ` + -SubscriptionId $script:correctionSubscriptionId + '' + } catch { + $_.Exception.Message + } + } + ) + + Assert-Equal ` + -Actual $script:nameAvailabilityCallCount ` + -Expected 2 ` + -Because 'both ordinary paths share the unchanged global-name safety check' + Assert-Equal ` + -Actual @($messages | Where-Object { + $_ -match "Global App Service name '$appName' is unavailable" + }).Count ` + -Expected 2 ` + -Because 'ordinary creation and ValidateOnly must still fail while the source app owns the name' + + $entrypoint = + [IO.File]::ReadAllText( + (Join-Path (Split-Path -Parent $PSScriptRoot) 'deploy-canvas-share-viewer.ps1')) + Assert-True ` + -Condition ($entrypoint.IndexOf( + 'Assert-ExistingResourceSafety', + [StringComparison]::Ordinal) -lt + $entrypoint.IndexOf( + 'if ($ValidateOnly)', + [StringComparison]::Ordinal)) ` + -Because 'ordinary validation must run the existing-resource safety guard before its read-only success exit' +} + +Invoke-TestCase 'post-move reconciliation requires explicit operator confirmation before tools run' { + Reset-CorrectionMocks -Scenario 'confirmation-gate' + $message = '' + + try { + Assert-CrossSubscriptionCorrectionParameters ` + -ReconcileCrossSubscriptionMove + } catch { + $message = $_.Exception.Message + } + + Assert-True ` + -Condition ($message -match 'ConfirmPortalMoveCompleted') ` + -Because 'reconciliation must name the explicit confirmation switch when it is absent' + Assert-Equal ` + -Actual ($script:azJsonCalls.Count + $script:azTryCalls.Count + $script:azNoneCalls.Count) ` + -Expected 0 ` + -Because 'the confirmation gate must not perform any Azure operation' + + Assert-CrossSubscriptionCorrectionParameters ` + -ReconcileCrossSubscriptionMove ` + -ConfirmPortalMoveCompleted + + $entrypoint = + [IO.File]::ReadAllText( + (Join-Path (Split-Path -Parent $PSScriptRoot) 'deploy-canvas-share-viewer.ps1')) + Assert-True ` + -Condition ($entrypoint.IndexOf( + 'Assert-CrossSubscriptionCorrectionParameters', + [StringComparison]::Ordinal) -lt + $entrypoint.IndexOf( + 'Assert-Prerequisites', + [StringComparison]::Ordinal)) ` + -Because 'the entry point must enforce confirmation before local tools or Azure are consulted' +} + +Invoke-TestCase 'post-move discovery and identity replacement use approved destination state only' { + Reset-CorrectionMocks -Scenario 'correction-destination' + $resources = + Get-CrossSubscriptionMoveDestinationResources ` + -SubscriptionId $script:correctionSubscriptionId + Assert-CrossSubscriptionMovedResources ` + -Existing $resources ` + -SubscriptionId $script:correctionSubscriptionId + + $resourceCalls = @($script:azTryCalls) + $unguardedCalls = + @( + $resourceCalls + | Where-Object { + $subscriptionIndex = + [Array]::IndexOf($_.Arguments, '--subscription') + $subscriptionIndex -lt 0 -or + [string] $_.Arguments[$subscriptionIndex + 1] -cne + $script:correctionSubscriptionId + } + ) + Assert-Equal ` + -Actual $unguardedCalls.Count ` + -Expected 0 ` + -Because 'post-move resource discovery must be scoped to the approved destination subscription' + + $webAppCall = + @( + $resourceCalls + | Where-Object { + ($_.Arguments[0..2] -join ' ') -eq 'rest --method get' -and + ($_.Arguments -join ' ') -match '/sites/treemon\?api-version=' + } + )[0] + $queryIndex = [Array]::IndexOf($webAppCall.Arguments, '--query') + Assert-True ` + -Condition ($queryIndex -ge 0 -and + [string] $webAppCall.Arguments[$queryIndex + 1] -notmatch '(?i)identity') ` + -Because 'initial destination discovery must not retrieve a source identity attachment' + Assert-Equal ` + -Actual $script:nameAvailabilityCallCount ` + -Expected 0 ` + -Because 'post-move discovery resolves the moved app and never checks global name availability' + + $script:azNoneCalls = @() + + Set-CrossSubscriptionReplacementIdentity ` + -ManagedIdentity $script:correctionIdentity ` + -SubscriptionId $script:correctionSubscriptionId + + $assignmentCall = @($script:azNoneCalls)[0] + $identitiesIndex = [Array]::IndexOf($assignmentCall.Arguments, '--identities') + Assert-Equal ` + -Actual ([string] $assignmentCall.Arguments[$identitiesIndex + 1]) ` + -Expected ([string] $script:correctionIdentity.id) ` + -Because 'the moved app identity set must be replaced with the prepared destination identity' + Assert-True ` + -Condition (($assignmentCall.Arguments -join ' ') -notmatch 'source-subscription|source-rg|source-identity') ` + -Because 'the identity assignment must not carry a source resource identifier' +} + Invoke-TestCase 'every Azure resource-plane call selects its subscription explicitly' { $resourceFamilies = @( @@ -449,7 +872,7 @@ Invoke-TestCase 'every Azure resource-plane call selects its subscription explic ) $unguardedCalls = @( - 'Azure.ps1', 'ViewerBlobAccess.ps1' + 'Azure.ps1', 'CrossSubscriptionCorrection.ps1', 'ViewerBlobAccess.ps1' | ForEach-Object { $path = Join-Path $PSScriptRoot $_ $tokens = $null diff --git a/scripts/deploy-canvas-share-viewer.ps1 b/scripts/deploy-canvas-share-viewer.ps1 index 1c846303..10dad3ad 100644 --- a/scripts/deploy-canvas-share-viewer.ps1 +++ b/scripts/deploy-canvas-share-viewer.ps1 @@ -1,6 +1,6 @@ #requires -Version 7.4 -[CmdletBinding()] +[CmdletBinding(DefaultParameterSetName = 'Deploy')] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] @@ -26,7 +26,17 @@ param( [ValidateNotNullOrEmpty()] [string] $Registration, - [switch] $ValidateOnly + [Parameter(ParameterSetName = 'Deploy')] + [switch] $ValidateOnly, + + [Parameter(Mandatory, ParameterSetName = 'PrepareMove')] + [switch] $PrepareCrossSubscriptionMove, + + [Parameter(Mandatory, ParameterSetName = 'ReconcileMove')] + [switch] $ReconcileCrossSubscriptionMove, + + [Parameter(ParameterSetName = 'ReconcileMove')] + [switch] $ConfirmPortalMoveCompleted ) Set-StrictMode -Version Latest @@ -48,11 +58,21 @@ $deploymentSupportDirectory = Join-Path $PSScriptRoot 'canvas-share-viewer-deplo . (Join-Path $deploymentSupportDirectory 'Common.ps1') . (Join-Path $deploymentSupportDirectory 'ViewerBlobAccess.ps1') . (Join-Path $deploymentSupportDirectory 'Azure.ps1') +. (Join-Path $deploymentSupportDirectory 'CrossSubscriptionCorrection.ps1') + +Assert-CrossSubscriptionCorrectionParameters ` + -ReconcileCrossSubscriptionMove:$ReconcileCrossSubscriptionMove ` + -ConfirmPortalMoveCompleted:$ConfirmPortalMoveCompleted Write-Step 'Validating local tools and repository inputs' Assert-Prerequisites $treemonConfig = Read-TreemonCanvasShareConfig -$desiredLifecycleRule = Get-LifecycleRule -Container $treemonConfig.Container +$desiredLifecycleRule = + if ($PrepareCrossSubscriptionMove) { + $null + } else { + Get-LifecycleRule -Container $treemonConfig.Container + } Write-Step 'Validating Azure subscription, tenant, and delegated publisher' $azureContext = Get-AzureContext ` @@ -65,10 +85,33 @@ $storageAccount = Get-StorageAccount ` $containerScope = Get-ShareContainerScope ` -StorageAccount $storageAccount ` -Container $treemonConfig.Container -$existingResources = Get-ExistingResources -SubscriptionId $azureContext.SubscriptionId -Assert-ExistingResourceSafety ` - -Existing $existingResources ` - -SubscriptionId $azureContext.SubscriptionId + +if ($PrepareCrossSubscriptionMove) { + Invoke-CrossSubscriptionMovePreparation ` + -StorageAccount $storageAccount ` + -Container $treemonConfig.Container ` + -ContainerScope $containerScope ` + -SubscriptionId $azureContext.SubscriptionId + return +} + +$existingResources = + if ($ReconcileCrossSubscriptionMove) { + Get-CrossSubscriptionMoveDestinationResources ` + -SubscriptionId $azureContext.SubscriptionId + } else { + Get-ExistingResources -SubscriptionId $azureContext.SubscriptionId + } + +if ($ReconcileCrossSubscriptionMove) { + Assert-CrossSubscriptionMovedResources ` + -Existing $existingResources ` + -SubscriptionId $azureContext.SubscriptionId +} else { + Assert-ExistingResourceSafety ` + -Existing $existingResources ` + -SubscriptionId $azureContext.SubscriptionId +} if ($null -ne $existingResources.Identity) { Write-Step 'Verifying the existing viewer identity has container-only Blob access' @@ -91,6 +134,20 @@ try { return } + if ($ReconcileCrossSubscriptionMove) { + Set-CrossSubscriptionReplacementIdentity ` + -ManagedIdentity $existingResources.Identity ` + -SubscriptionId $azureContext.SubscriptionId + $existingResources = + Get-ExistingResources -SubscriptionId $azureContext.SubscriptionId + Assert-CrossSubscriptionMovedResources ` + -Existing $existingResources ` + -SubscriptionId $azureContext.SubscriptionId + Assert-ExistingResourceSafety ` + -Existing $existingResources ` + -SubscriptionId $azureContext.SubscriptionId + } + $group = Ensure-ResourceGroup ` -ExistingGroup $existingResources.Group ` -StorageAccount $storageAccount ` @@ -168,7 +225,11 @@ try { } Write-Host '' - Write-Host "Deployment complete: $viewerBaseUrl" + if ($ReconcileCrossSubscriptionMove) { + Write-Host "Post-move reconciliation complete: $viewerBaseUrl" + } else { + Write-Host "Deployment complete: $viewerBaseUrl" + } Write-Host 'The canonical App Service, identity, Entra configuration, RBAC grants, and lifecycle policy remain deployed.' } finally { Remove-Item -LiteralPath $workingDirectory -Recurse -Force -ErrorAction SilentlyContinue From ffab63bc0b55bfd124f6dcb82136a12297fed3ee Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Thu, 20 Aug 2026 15:17:21 +0200 Subject: [PATCH 19/27] tm-canvas-safe-share-6f6 Fix F2/F3 Azure.ps1:252,266 extraction and auth diagnostics Extract subscription resolution and Azure-context approval into SubscriptionGuard.ps1 dot-sourced after Common.ps1, resolve the selected Azure CLI account before either private subscription lookup so authentication failures keep their real diagnostic, and cover both with deployment regressions. --- docs/spec/canvas-sharing.md | 5 + .../canvas-share-viewer-deployment/Azure.ps1 | 115 ------------ .../Deployment.Tests.ps1 | 169 +++++++++++++++++- .../SubscriptionGuard.ps1 | 114 ++++++++++++ scripts/deploy-canvas-share-viewer.ps1 | 1 + 5 files changed, 288 insertions(+), 116 deletions(-) create mode 100644 scripts/canvas-share-viewer-deployment/SubscriptionGuard.ps1 diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index b56131c1..019909e2 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -253,6 +253,9 @@ remote-URL fetch that would otherwise be a working exfiltration channel. the selected CLI account must all resolve to the same enabled subscription -- so a name and an ID naming the same subscription agree, while a raw-string near-miss cannot pass. Failure diagnostics name the configuration key and the mismatch, never the values. + The selected account resolves before either configured subscription value is looked up, so an + unusable Azure CLI sign-in keeps its own actionable authentication error instead of being + reported as a configuration problem. The guard is mandatory and independent of whatever restrictions the operator's environment places on direct Azure CLI use. An environment-level control can only see the commands issued to it, not the `az` child processes this script spawns, so the script proves its own target on every @@ -412,6 +415,8 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | `src/Client/CanvasPane.fs`, `CanvasState.fs`, `CanvasUpdate.fs`, `index.html` | Share button, `ShareState` phase machine, clipboard write and banner routing (unchanged) | | `src/CanvasShareViewer/` | New App Service viewer: shell route, content route, expiry check, sandbox/CSP, Easy Auth configuration | | `scripts/deploy-canvas-share-viewer.ps1` | Idempotent non-production Azure provisioning, secret-free Easy Auth, Entra-authenticated ZIP deployment, validation, and machine-config update | +| `scripts/canvas-share-viewer-deployment/Common.ps1` | Shared Azure CLI invocation and machine-configuration boundary for deployment helpers | +| `scripts/canvas-share-viewer-deployment/SubscriptionGuard.ps1` | Fail-closed approved/requested/selected subscription, tenant, and delegated-publisher validation with private lookup diagnostics redacted | | `scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1` | Approved-destination preparation, redacted portal handoff, post-move discovery, and replacement-identity attachment | | `scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1` | Windows/Azure CLI shape, packaging-output, reconciliation, restricted-tenant registration, and Easy Auth callback regressions | | `scripts/canvas-share-viewer-deployment/ViewerBlobAccess.ps1` | Fail-closed audit of the viewer identity's effective Blob-read data-plane assignments | diff --git a/scripts/canvas-share-viewer-deployment/Azure.ps1 b/scripts/canvas-share-viewer-deployment/Azure.ps1 index ff646ec5..f166ed7c 100644 --- a/scripts/canvas-share-viewer-deployment/Azure.ps1 +++ b/scripts/canvas-share-viewer-deployment/Azure.ps1 @@ -249,121 +249,6 @@ function Assert-ExistingResourceSafety { } } -function Resolve-EnabledAzureSubscription { - param( - [Parameter(Mandatory)] - [AllowEmptyString()] - [string] $Value, - - [Parameter(Mandatory)] - [string] $Source - ) - - if ([string]::IsNullOrWhiteSpace($Value)) { - throw "$Source is absent or blank." - } - - try { - $account = Invoke-AzJson -Arguments @( - 'account', 'show', - '--subscription', $Value) - } catch { - throw "$Source could not be resolved uniquely to an enabled Azure subscription." - } - - if ($null -eq $account -or $account -is [array]) { - throw "$Source could not be resolved uniquely to an enabled Azure subscription." - } - - $idProperty = $account.PSObject.Properties['id'] - $stateProperty = $account.PSObject.Properties['state'] - - if ($null -eq $idProperty -or - $null -eq $stateProperty -or - [string]::IsNullOrWhiteSpace([string] $idProperty.Value) -or - -not [string]::Equals( - [string] $stateProperty.Value, - 'Enabled', - [StringComparison]::OrdinalIgnoreCase)) { - throw "$Source could not be resolved uniquely to an enabled Azure subscription." - } - - $account -} - -function Get-AzureContext { - param( - [Parameter(Mandatory)] - [AllowEmptyString()] - [string] $ApprovedSubscription, - - [Parameter(Mandatory)] - [string] $RequestedSubscription, - - [Parameter(Mandatory)] - [string] $RequestedTenant - ) - - if ([string]::IsNullOrWhiteSpace($ApprovedSubscription)) { - throw 'Treemon machine configuration must set canvasShare.approvedSubscription before viewer deployment.' - } - - $cloud = Invoke-AzJson -Arguments @('cloud', 'show') - if ([string] $cloud.name -cne 'AzureCloud') { - throw "The canonical azurewebsites.net deployment requires the AzureCloud environment. Current cloud: $($cloud.name)." - } - - $approvedAccount = Resolve-EnabledAzureSubscription ` - -Value $ApprovedSubscription ` - -Source 'canvasShare.approvedSubscription' - $requestedAccount = Resolve-EnabledAzureSubscription ` - -Value $RequestedSubscription ` - -Source 'The -Subscription input' - - if (-not [string]::Equals( - [string] $approvedAccount.id, - [string] $requestedAccount.id, - [StringComparison]::OrdinalIgnoreCase)) { - throw 'Azure subscription mismatch: -Subscription must resolve to canvasShare.approvedSubscription.' - } - - $currentAccount = Invoke-AzJson -Arguments @('account', 'show') - - if ([string]::IsNullOrWhiteSpace([string] $currentAccount.id) -or - -not [string]::Equals( - [string] $currentAccount.state, - 'Enabled', - [StringComparison]::OrdinalIgnoreCase)) { - throw 'The selected Azure CLI account does not identify an enabled subscription.' - } - - if (-not [string]::Equals( - [string] $approvedAccount.id, - [string] $currentAccount.id, - [StringComparison]::OrdinalIgnoreCase)) { - throw 'Azure subscription mismatch: the selected Azure CLI account must resolve to canvasShare.approvedSubscription.' - } - - if (-not [string]::Equals( - [string] $approvedAccount.tenantId, - $RequestedTenant, - [StringComparison]::OrdinalIgnoreCase)) { - throw 'The requested tenant does not own the selected subscription.' - } - - if ([string] $currentAccount.user.type -cne 'user') { - throw 'Sign in to Azure CLI with the delegated publisher user before running this script.' - } - - $publisher = Invoke-AzJson -Arguments @('ad', 'signed-in-user', 'show') - - [pscustomobject]@{ - SubscriptionId = [string] $approvedAccount.id - TenantId = [string] $approvedAccount.tenantId - PublisherObjectId = [string] $publisher.id - } -} - function Ensure-ResourceGroup { param( [AllowNull()][pscustomobject] $ExistingGroup, diff --git a/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 b/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 index 75184509..492aa885 100644 --- a/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 +++ b/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 @@ -10,6 +10,7 @@ $minimumLifecycleDays = 31 $viewerBaseUrl = 'https://treemon.azurewebsites.net' . (Join-Path $PSScriptRoot 'Common.ps1') +. (Join-Path $PSScriptRoot 'SubscriptionGuard.ps1') function Assert-Equal { param( @@ -44,6 +45,24 @@ function Invoke-TestCase { Write-Host "PASS: $Name" } +Invoke-TestCase 'deployment entry point loads the subscription guard after Common and before Azure' { + $entryPoint = + [IO.File]::ReadAllText( + (Join-Path $repoRoot 'scripts' 'deploy-canvas-share-viewer.ps1')) + $commonIndex = + $entryPoint.IndexOf("'Common.ps1'", [StringComparison]::Ordinal) + $subscriptionGuardIndex = + $entryPoint.IndexOf("'SubscriptionGuard.ps1'", [StringComparison]::Ordinal) + $azureIndex = + $entryPoint.IndexOf("'Azure.ps1'", [StringComparison]::Ordinal) + + Assert-True ` + -Condition ($commonIndex -ge 0 -and + $subscriptionGuardIndex -gt $commonIndex -and + $azureIndex -gt $subscriptionGuardIndex) ` + -Because 'the subscription guard depends on Common and must be available before Azure deployment orchestration' +} + function dotnet { $arguments = @($args) $outputIndex = [Array]::IndexOf($arguments, '--output') @@ -106,7 +125,9 @@ $script:azJsonCalls = @() $script:azTryCalls = @() $script:blobAccessAudits = @() $script:subscriptionAccounts = @{} +$script:subscriptionLookupFailures = @{} $script:selectedAccount = $null +$script:selectedAccountFailure = '' $script:publisherResult = $null $script:createCallCount = 0 $script:nameAvailabilityCallCount = 0 @@ -200,6 +221,10 @@ function Invoke-AzJson { $subscriptionIndex = [Array]::IndexOf($Arguments, '--subscription') $subscription = [string] $Arguments[$subscriptionIndex + 1] + if ($script:subscriptionLookupFailures.ContainsKey($subscription)) { + throw [string] $script:subscriptionLookupFailures[$subscription] + } + if (-not $script:subscriptionAccounts.ContainsKey($subscription)) { throw 'Synthetic subscription is unavailable.' } @@ -207,6 +232,10 @@ function Invoke-AzJson { return $script:subscriptionAccounts[$subscription] } 'account show' { + if (-not [string]::IsNullOrWhiteSpace($script:selectedAccountFailure)) { + throw $script:selectedAccountFailure + } + return $script:selectedAccount } 'ad signed-in-user show' { @@ -382,7 +411,9 @@ function Reset-AzureContextMocks { $script:scenario = 'azure-context' $script:azJsonCalls = @() $script:subscriptionAccounts = @{} + $script:subscriptionLookupFailures = @{} $script:selectedAccount = $null + $script:selectedAccountFailure = '' $script:publisherResult = [pscustomobject]@{ id = 'dddddddd-dddd-dddd-dddd-dddddddddddd' @@ -493,6 +524,139 @@ Invoke-TestCase 'requested subscription mismatch fails before privileged Azure c $otherSubscriptionId) } +Invoke-TestCase 'selected-account authentication failure preserves the Azure CLI diagnostic' { + Reset-AzureContextMocks + $script:selectedAccountFailure = + "az account show failed: Please run 'az login' to set up an account." + $message = '' + + try { + Get-AzureContext ` + -ApprovedSubscription $approvedSubscription ` + -RequestedSubscription $requestedSubscription ` + -RequestedTenant $tenantId | + Out-Null + } catch { + $message = $_.Exception.Message + } + + Assert-True ` + -Condition ($message -match "Please run 'az login'") ` + -Because 'an unavailable selected account must retain the actionable Azure CLI authentication diagnostic' + $subscriptionLookups = + @( + $script:azJsonCalls + | Where-Object { $_.Arguments -contains '--subscription' } + ) + Assert-Equal ` + -Actual $subscriptionLookups.Count ` + -Expected 0 ` + -Because 'the selected account must resolve before either private subscription value is used' + Assert-NoPrivilegedAzureCalls + Assert-SubscriptionValuesRedacted ` + -Message $message ` + -Values @($approvedSubscription, $requestedSubscription) +} + +Invoke-TestCase 'private subscription lookup failures are sanitized' { + Reset-AzureContextMocks + $approvedAccount = + New-SyntheticAccount ` + -Id $approvedSubscriptionId ` + -TenantId $tenantId + $script:selectedAccount = $approvedAccount + $privateFailureMarker = 'private-lookup-stderr' + $script:subscriptionLookupFailures[$approvedSubscription] = + "$privateFailureMarker $approvedSubscription $approvedSubscriptionId" + $message = '' + + try { + Get-AzureContext ` + -ApprovedSubscription $approvedSubscription ` + -RequestedSubscription $requestedSubscription ` + -RequestedTenant $tenantId | + Out-Null + } catch { + $message = $_.Exception.Message + } + + Assert-True ` + -Condition ($message -match 'canvasShare\.approvedSubscription' -and + $message -match 'could not be resolved') ` + -Because 'private-value lookup failures must identify only the protected configuration source' + Assert-SubscriptionValuesRedacted ` + -Message $message ` + -Values @( + $approvedSubscription, + $approvedSubscriptionId, + $privateFailureMarker) + Assert-NoPrivilegedAzureCalls +} + +Invoke-TestCase 'missing approved subscription fails closed with a sanitized diagnostic' { + Reset-AzureContextMocks + $script:selectedAccount = + New-SyntheticAccount ` + -Id $approvedSubscriptionId ` + -TenantId $tenantId + $message = '' + + try { + Get-AzureContext ` + -ApprovedSubscription $approvedSubscription ` + -RequestedSubscription $requestedSubscription ` + -RequestedTenant $tenantId | + Out-Null + } catch { + $message = $_.Exception.Message + } + + Assert-True ` + -Condition ($message -match 'canvasShare\.approvedSubscription' -and + $message -match 'could not be resolved') ` + -Because 'a missing approved subscription must fail closed' + Assert-SubscriptionValuesRedacted ` + -Message $message ` + -Values @($approvedSubscription, $approvedSubscriptionId) + Assert-NoPrivilegedAzureCalls +} + +Invoke-TestCase 'disabled requested subscription fails closed with a sanitized diagnostic' { + Reset-AzureContextMocks + $approvedAccount = + New-SyntheticAccount ` + -Id $approvedSubscriptionId ` + -TenantId $tenantId + $disabledRequestedAccount = + New-SyntheticAccount ` + -Id $approvedSubscriptionId ` + -TenantId $tenantId + $disabledRequestedAccount.state = 'Disabled' + $script:selectedAccount = $approvedAccount + $script:subscriptionAccounts[$approvedSubscription] = $approvedAccount + $script:subscriptionAccounts[$requestedSubscription] = $disabledRequestedAccount + $message = '' + + try { + Get-AzureContext ` + -ApprovedSubscription $approvedSubscription ` + -RequestedSubscription $requestedSubscription ` + -RequestedTenant $tenantId | + Out-Null + } catch { + $message = $_.Exception.Message + } + + Assert-True ` + -Condition ($message -match 'The -Subscription input' -and + $message -match 'could not be resolved') ` + -Because 'a disabled requested subscription must fail closed' + Assert-SubscriptionValuesRedacted ` + -Message $message ` + -Values @($requestedSubscription, $approvedSubscriptionId) + Assert-NoPrivilegedAzureCalls +} + Invoke-TestCase 'selected Azure account mismatch fails before privileged Azure calls' { Reset-AzureContextMocks $approvedAccount = @@ -872,7 +1036,10 @@ Invoke-TestCase 'every Azure resource-plane call selects its subscription explic ) $unguardedCalls = @( - 'Azure.ps1', 'CrossSubscriptionCorrection.ps1', 'ViewerBlobAccess.ps1' + 'SubscriptionGuard.ps1', + 'Azure.ps1', + 'CrossSubscriptionCorrection.ps1', + 'ViewerBlobAccess.ps1' | ForEach-Object { $path = Join-Path $PSScriptRoot $_ $tokens = $null diff --git a/scripts/canvas-share-viewer-deployment/SubscriptionGuard.ps1 b/scripts/canvas-share-viewer-deployment/SubscriptionGuard.ps1 new file mode 100644 index 00000000..1f986d1b --- /dev/null +++ b/scripts/canvas-share-viewer-deployment/SubscriptionGuard.ps1 @@ -0,0 +1,114 @@ +function Resolve-EnabledAzureSubscription { + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Value, + + [Parameter(Mandatory)] + [string] $Source + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { + throw "$Source is absent or blank." + } + + try { + $account = Invoke-AzJson -Arguments @( + 'account', 'show', + '--subscription', $Value) + } catch { + throw "$Source could not be resolved uniquely to an enabled Azure subscription." + } + + if ($null -eq $account -or $account -is [array]) { + throw "$Source could not be resolved uniquely to an enabled Azure subscription." + } + + $idProperty = $account.PSObject.Properties['id'] + $stateProperty = $account.PSObject.Properties['state'] + + if ($null -eq $idProperty -or + $null -eq $stateProperty -or + [string]::IsNullOrWhiteSpace([string] $idProperty.Value) -or + -not [string]::Equals( + [string] $stateProperty.Value, + 'Enabled', + [StringComparison]::OrdinalIgnoreCase)) { + throw "$Source could not be resolved uniquely to an enabled Azure subscription." + } + + $account +} + +function Get-AzureContext { + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $ApprovedSubscription, + + [Parameter(Mandatory)] + [string] $RequestedSubscription, + + [Parameter(Mandatory)] + [string] $RequestedTenant + ) + + if ([string]::IsNullOrWhiteSpace($ApprovedSubscription)) { + throw 'Treemon machine configuration must set canvasShare.approvedSubscription before viewer deployment.' + } + + $cloud = Invoke-AzJson -Arguments @('cloud', 'show') + if ([string] $cloud.name -cne 'AzureCloud') { + throw "The canonical azurewebsites.net deployment requires the AzureCloud environment. Current cloud: $($cloud.name)." + } + + $currentAccount = Invoke-AzJson -Arguments @('account', 'show') + + if ([string]::IsNullOrWhiteSpace([string] $currentAccount.id) -or + -not [string]::Equals( + [string] $currentAccount.state, + 'Enabled', + [StringComparison]::OrdinalIgnoreCase)) { + throw 'The selected Azure CLI account does not identify an enabled subscription.' + } + + $approvedAccount = Resolve-EnabledAzureSubscription ` + -Value $ApprovedSubscription ` + -Source 'canvasShare.approvedSubscription' + $requestedAccount = Resolve-EnabledAzureSubscription ` + -Value $RequestedSubscription ` + -Source 'The -Subscription input' + + if (-not [string]::Equals( + [string] $approvedAccount.id, + [string] $requestedAccount.id, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'Azure subscription mismatch: -Subscription must resolve to canvasShare.approvedSubscription.' + } + + if (-not [string]::Equals( + [string] $approvedAccount.id, + [string] $currentAccount.id, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'Azure subscription mismatch: the selected Azure CLI account must resolve to canvasShare.approvedSubscription.' + } + + if (-not [string]::Equals( + [string] $approvedAccount.tenantId, + $RequestedTenant, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'The requested tenant does not own the selected subscription.' + } + + if ([string] $currentAccount.user.type -cne 'user') { + throw 'Sign in to Azure CLI with the delegated publisher user before running this script.' + } + + $publisher = Invoke-AzJson -Arguments @('ad', 'signed-in-user', 'show') + + [pscustomobject]@{ + SubscriptionId = [string] $approvedAccount.id + TenantId = [string] $approvedAccount.tenantId + PublisherObjectId = [string] $publisher.id + } +} diff --git a/scripts/deploy-canvas-share-viewer.ps1 b/scripts/deploy-canvas-share-viewer.ps1 index 10dad3ad..58568c9b 100644 --- a/scripts/deploy-canvas-share-viewer.ps1 +++ b/scripts/deploy-canvas-share-viewer.ps1 @@ -56,6 +56,7 @@ $lifecyclePolicyPath = Join-Path $PSScriptRoot 'canvas-share-lifecycle-policy.js $deploymentSupportDirectory = Join-Path $PSScriptRoot 'canvas-share-viewer-deployment' . (Join-Path $deploymentSupportDirectory 'Common.ps1') +. (Join-Path $deploymentSupportDirectory 'SubscriptionGuard.ps1') . (Join-Path $deploymentSupportDirectory 'ViewerBlobAccess.ps1') . (Join-Path $deploymentSupportDirectory 'Azure.ps1') . (Join-Path $deploymentSupportDirectory 'CrossSubscriptionCorrection.ps1') From f103eceb5f4e959ce6210c299bee1b5ca7fb20b8 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Thu, 20 Aug 2026 16:39:57 +0200 Subject: [PATCH 20/27] tm-canvas-safe-share-d75 Reconcile moved app identity attachment instead of appending to it az webapp identity assign appends rather than replaces, so post-move reconciliation could leave a stale source-subscription identity attached. Removing it via CLI would require naming its source resource ID, which automation may never carry. Reconciliation now reads a sanitized destination-side summary (identity type, attachment count, prepared-identity match) before any App Service mutation and fails closed with portal guidance on a system-assigned identity or any foreign user-assigned attachment. The portal checklist detaches the attachment before the move, and the spec and operator doc record the detachment window and manual reattach on rollback. --- docs/canvas-share-viewer-deployment.md | 29 +++++-- docs/spec/canvas-sharing.md | 29 ++++--- .../CrossSubscriptionCorrection.ps1 | 48 +++++++++-- .../Deployment.Tests.ps1 | 85 ++++++++++++++++++- 4 files changed, 164 insertions(+), 27 deletions(-) diff --git a/docs/canvas-share-viewer-deployment.md b/docs/canvas-share-viewer-deployment.md index 925cfd47..c9fb75e5 100644 --- a/docs/canvas-share-viewer-deployment.md +++ b/docs/canvas-share-viewer-deployment.md @@ -104,11 +104,15 @@ Reader` assignment. It does not check global availability of `treemon`, create o Service or plan, mutate the app registration, change storage configuration, build or deploy the viewer, or write Treemon configuration. It finishes by printing a redacted portal checklist. -Follow that checklist in the Azure portal: move the canonical `treemon` App Service and its App -Service plan together into the prepared destination. Leave source-side identities, role -assignments, storage, and resource groups in place for rollback. Do not substitute an ordinary -`-ValidateOnly` or apply run for preparation; before the portal move those modes correctly stop -because the existing app still owns the global name. +Follow that checklist in the Azure portal. First detach the App Service's current user-assigned +identity attachment and confirm that its system-assigned identity is off; leave the detached +identity resource, its role assignments, storage, and resource groups in place for rollback. Then +move the canonical `treemon` App Service and its App Service plan together into the prepared +destination. Do not substitute an ordinary `-ValidateOnly` or apply run for preparation; before +the portal move those modes correctly stop because the existing app still owns the global name. +The viewer is expected to be unavailable from detachment until reconciliation finishes. If the +portal move fails before the app reaches the destination, manually reattach the preserved former +identity before retrying or restoring service. After the portal reports success, use an isolated `TREEMON_CONFIG_DIR` seeded with the approved subscription, storage account, and container from the private machine configuration, then run: @@ -127,10 +131,17 @@ subscription, storage account, and container from the private machine configurat The confirmation switch is mandatory and is checked before local tools or Azure are consulted. Reconciliation requires the moved canonical app, its plan, the prepared identity, and the retained -registration to exist. It replaces the app's identity attachment with the destination identity, -then reconciles federation, private-container RBAC, settings, lifecycle policy, package, Easy Auth, -and deployed state through approved-subscription-only calls. The isolated config receives the -canonical `viewerBaseUrl`; the production config remains unchanged. +registration to exist. Before changing the app, it reads only a sanitized count and +prepared-identity match from the moved app's destination-side identity state. Any other +user-assigned attachment or a system-assigned identity stops reconciliation with portal guidance +and no App Service mutation; detach it in the portal and rerun. Once that preflight passes, +reconciliation attaches the prepared destination identity, then reconciles federation, +private-container RBAC, settings, lifecycle policy, package, Easy Auth, and deployed state through +approved-subscription-only calls. The isolated config receives the canonical `viewerBaseUrl`; the +production config remains unchanged. + +If the app must be moved back for rollback, move the app and plan together and manually reattach +the preserved former identity afterwards. ## Provision and deploy diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index 019909e2..3902351b 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -316,18 +316,23 @@ remote-URL fetch that would otherwise be a working exfiltration channel. group, replacement user-assigned identity, and its container-scoped reader grant; it neither checks the global app name nor reads or creates an App Service or plan. `-ReconcileCrossSubscriptionMove` requires `-ConfirmPortalMoveCompleted` before any tool or Azure - call, resolves the moved app and plan only in the approved destination, replaces the app's - identity attachment with the prepared identity, and reconciles the retained tenant-scoped app + call, resolves the moved app and plan only in the approved destination, and checks a sanitized + destination-side summary of the app's identity attachments before changing the app. A + system-assigned identity or any user-assigned identity other than the prepared destination + identity fails with portal guidance and no App Service mutation. Once the check passes, + reconciliation attaches the prepared identity and reconciles the retained tenant-scoped app registration, federated credential, container RBAC, settings, package, and deployed state. - Between those modes the operator performs the move in the Azure portal using the redacted - checklist. Automation never queries, validates, or mutates anything in the source subscription - and never carries a resource ID, subscription, or tenant identifier belonging to it; the - checklist identifies what to move by the canonical App Service and plan names. + Between those modes the redacted portal checklist has the operator detach the app's current + user-assigned identity attachment, leave that identity resource and its roles in place, then move + the app and plan together. Automation never queries, validates, or mutates anything in the source + subscription and never carries a resource ID, subscription, or tenant identifier belonging to + it; the checklist identifies what to detach and move by resource type and the canonical App + Service and plan names. Source-side leftovers -- the obsolete identity, its role assignments, any stand-in verification storage, and the former resource groups -- stay in place until the move is independently - verified, so the operator can move the app and plan back while automation restores the previous - identity reference, credential subject, and settings. Removing them is a separate, explicitly - approved operator step, not part of the move. + verified. Identity detachment makes the viewer unavailable until reconciliation; a failed or + rolled-back move requires the operator to reattach the preserved former identity in the portal. + Removing those leftovers is a separate, explicitly approved operator step, not part of the move. The source app still holds the global name until it moves, so a pre-move `-ValidateOnly` run against the destination subscription is expected to stop at the name-availability check. That check is not relaxed for the correction: the operator's move is what places the app in the @@ -390,6 +395,7 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | Keep the in-script guard even when the environment already restricts direct CLI use | A control that filters issued commands cannot observe the `az` child processes a deployment script starts, so it stops covering exactly the operations this feature automates. The script's own check is the only one present inside a run, and an outer restriction is never accepted as a reason to remove or weaken it. | | Move the App Service and plan together when correcting subscription placement | An ARM move preserves the globally unique `treemon.azurewebsites.net` name; deleting and recreating the app would briefly release that name and make rollback dependent on reacquiring it. | | Have the operator perform the move in the portal rather than automating it | Automation is confined to the approved subscription and cannot query or validate the source one, so it cannot issue the move at all. Splitting the correction into prepare / operator move / reconcile keeps the one irreversible step under direct human control and leaves automation with only operations it is allowed to perform. | +| Refuse a foreign app identity before post-move mutation | Removing a stale user-assigned identity through Azure CLI requires naming that identity's resource ID, which would carry a source-subscription identifier into automation. The portal checklist detaches the app attachment before the move; reconciliation reads only a sanitized destination-side count and prepared-identity match, fails before mutation if another attachment remains, and stays idempotent when only the prepared identity is already attached. | | Expose preparation and reconciliation as mutually exclusive deployment-script modes | Preparation must bypass the ordinary global-name guard without weakening it, while reconciliation must not begin from an unconfirmed portal handoff. Separate parameter sets and an explicit post-move confirmation make those safety boundaries visible at invocation time. | | Decommission by exact resource allowlist rather than broad scope | Shared subscriptions can contain unrelated workloads. Fresh inventory, drift checks, and individual resource-ID deletion make collateral changes falsifiable and leave ambiguous resources untouched. | | Reuse one unambiguous publisher-owned `serviceManagementReference` only when Entra requires it | Restricted tenants reject registration mutations without their organizational service reference, while an arbitrary GUID can be invalid or misrepresent ownership. Conditional discovery keeps the normal path unchanged, adds no secret or deployment-name input, and fails closed when publisher-owned state cannot identify one value. | @@ -417,7 +423,7 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | `scripts/deploy-canvas-share-viewer.ps1` | Idempotent non-production Azure provisioning, secret-free Easy Auth, Entra-authenticated ZIP deployment, validation, and machine-config update | | `scripts/canvas-share-viewer-deployment/Common.ps1` | Shared Azure CLI invocation and machine-configuration boundary for deployment helpers | | `scripts/canvas-share-viewer-deployment/SubscriptionGuard.ps1` | Fail-closed approved/requested/selected subscription, tenant, and delegated-publisher validation with private lookup diagnostics redacted | -| `scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1` | Approved-destination preparation, redacted portal handoff, post-move discovery, and replacement-identity attachment | +| `scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1` | Approved-destination preparation, redacted identity-detachment/move handoff, sanitized post-move identity preflight, and prepared-identity attachment | | `scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1` | Windows/Azure CLI shape, packaging-output, reconciliation, restricted-tenant registration, and Easy Auth callback regressions | | `scripts/canvas-share-viewer-deployment/ViewerBlobAccess.ps1` | Fail-closed audit of the viewer identity's effective Blob-read data-plane assignments | | `scripts/canvas-share-lifecycle-policy.json` | Container-filtered deletion rule starting after 31 days | @@ -455,6 +461,9 @@ remote-URL fetch that would otherwise be a working exfiltration channel. - Deployment against a subscription other than the machine-private approved one exits non-zero before any resource-provider or Entra application call, and its output contains no exact subscription name or ID; the approved context proceeds normally. +- A moved app that still reports a foreign user-assigned identity fails reconciliation before any + App Service mutation, exposes no attached identity ID, and tells the operator to detach it in the + portal before retrying. - After the cross-subscription correction the App Service and its plan are in the approved subscription, still answer on `https://treemon.azurewebsites.net`, and the authenticated share lifecycle -- publish, view, expiry, revocation, containment, fixed 503 -- still passes end to diff --git a/scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1 b/scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1 index 288d61be..131fa2ab 100644 --- a/scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1 +++ b/scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1 @@ -176,7 +176,43 @@ function Set-CrossSubscriptionReplacementIdentity { [Parameter(Mandatory)][string] $SubscriptionId ) - Write-Step 'Replacing the moved App Service identity attachment' + $preparedClientId = [string] $ManagedIdentity.clientId + if ([string]::IsNullOrWhiteSpace($preparedClientId)) { + throw 'The prepared destination identity has no client ID. No App Service change was made.' + } + + $preparedClientIdLiteral = + ConvertTo-Json -InputObject $preparedClientId -Compress + $identitySummaryQuery = + '{{identityType:identity.type,userAssignedIdentityCount:length(values(identity.userAssignedIdentities || `{{}}`)),preparedIdentityAttached:contains(values(identity.userAssignedIdentities || `{{}}`)[].clientId, `{0}`)}}' ` + -f $preparedClientIdLiteral + $identitySummary = Invoke-AzJson -Arguments @( + 'rest', + '--method', 'get', + '--uri', "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Web/sites/${appName}?api-version=2023-12-01", + '--query', $identitySummaryQuery, + '--subscription', $SubscriptionId) + + if ($null -eq $identitySummary -or + $null -eq $identitySummary.PSObject.Properties['identityType'] -or + $null -eq $identitySummary.PSObject.Properties['userAssignedIdentityCount'] -or + $null -eq $identitySummary.PSObject.Properties['preparedIdentityAttached']) { + throw 'The moved App Service identity attachment could not be verified. No App Service change was made.' + } + + $expectedIdentityCount = + if ([bool] $identitySummary.preparedIdentityAttached) { + 1 + } else { + 0 + } + + if ([string] $identitySummary.identityType -match 'SystemAssigned' -or + [int] $identitySummary.userAssignedIdentityCount -ne $expectedIdentityCount) { + throw 'The moved App Service still has an identity attachment other than the prepared destination identity. In the Azure portal, detach every other user-assigned identity and turn off its system-assigned identity, then rerun reconciliation. No App Service change was made.' + } + + Write-Step 'Attaching the prepared identity to the moved App Service' Invoke-AzNone -Arguments @( 'webapp', 'identity', 'assign', '--name', $appName, @@ -188,10 +224,12 @@ function Set-CrossSubscriptionReplacementIdentity { function Write-CrossSubscriptionMoveChecklist { Write-Host '' Write-Host 'Destination preparation complete. No source-subscription operation was issued.' - Write-Host "1. In the Azure portal, move the canonical App Service '$appName' and App Service plan '$Plan' together." - Write-Host '2. Select the approved destination subscription and the prepared destination resource group used for this run.' - Write-Host '3. Leave source-side identities, role assignments, storage, and resource groups unchanged for rollback.' - Write-Host '4. Wait for the portal move to succeed, seed an isolated TREEMON_CONFIG_DIR, then rerun the same command with -ReconcileCrossSubscriptionMove -ConfirmPortalMoveCompleted.' + Write-Host "1. In the Azure portal, open the canonical App Service '$appName', detach its user-assigned identity attachment, and confirm that System assigned is Off." + Write-Host '2. Leave the detached identity resource, role assignments, storage, and resource groups unchanged for rollback.' + Write-Host "3. Move the canonical App Service '$appName' and App Service plan '$Plan' together." + Write-Host '4. Select the approved destination subscription and the prepared destination resource group used for this run.' + Write-Host '5. Wait for the portal move to succeed, seed an isolated TREEMON_CONFIG_DIR, then rerun the same command with -ReconcileCrossSubscriptionMove -ConfirmPortalMoveCompleted.' + Write-Host 'The viewer is expected to be unavailable after identity detachment until reconciliation finishes. If the move fails, reattach the preserved identity in the portal before retrying.' Write-Host 'The automation does not accept or print a source subscription, tenant, resource group, or resource ID.' } diff --git a/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 b/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 index 492aa885..08d0155b 100644 --- a/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 +++ b/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 @@ -172,6 +172,12 @@ $script:correctionWebApp = appServicePlanId = $script:planResourceId httpsOnly = $true } +$script:correctionIdentityAttachmentSummary = + [pscustomobject]@{ + identityType = $null + userAssignedIdentityCount = 0 + preparedIdentityAttached = $false + } $script:webAppResult = [pscustomobject]@{ appServicePlanId = $script:planResourceId @@ -286,6 +292,13 @@ function Invoke-AzJson { $command = $Arguments[0..2] -join ' ' switch ($command) { + 'rest --method get' { + if (($Arguments -join ' ') -match '/sites/treemon\?api-version=') { + return $script:correctionIdentityAttachmentSummary + } + + throw 'Unexpected destination REST read in correction test.' + } 'ad app list' { return @($script:registrationResult) } @@ -429,6 +442,12 @@ function Reset-CorrectionMocks { $script:azTryCalls = @() $script:blobAccessAudits = @() $script:nameAvailabilityCallCount = 0 + $script:correctionIdentityAttachmentSummary = + [pscustomobject]@{ + identityType = $null + userAssignedIdentityCount = 0 + preparedIdentityAttached = $false + } } function New-SyntheticAccount { @@ -847,8 +866,9 @@ Invoke-TestCase 'pre-move preparation bypasses the unavailable global app name s -Because 'the newly prepared identity must be audited after its reader grant' Assert-True ` -Condition ($outputText -match 'Destination preparation complete' -and + $outputText -match 'detach its user-assigned identity attachment' -and $outputText -match 'ConfirmPortalMoveCompleted') ` - -Because 'preparation must emit the portal handoff and the explicit reconciliation command' + -Because 'preparation must emit the identity-detachment handoff and the explicit reconciliation command' Assert-True ` -Condition ($outputText -notmatch '(?i)/subscriptions/|tenant-id|source-rg|source-identity|[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}') ` -Because 'the emitted checklist must not contain source subscription, tenant, group, identity, or resource IDs' @@ -962,7 +982,7 @@ Invoke-TestCase 'post-move reconciliation requires explicit operator confirmatio -Because 'the entry point must enforce confirmation before local tools or Azure are consulted' } -Invoke-TestCase 'post-move discovery and identity replacement use approved destination state only' { +Invoke-TestCase 'post-move discovery and identity attachment use approved destination state only' { Reset-CorrectionMocks -Scenario 'correction-destination' $resources = Get-CrossSubscriptionMoveDestinationResources ` @@ -1012,17 +1032,76 @@ Invoke-TestCase 'post-move discovery and identity replacement use approved desti -ManagedIdentity $script:correctionIdentity ` -SubscriptionId $script:correctionSubscriptionId + $identitySummaryCall = + @( + $script:azJsonCalls + | Where-Object { + ($_.Arguments[0..2] -join ' ') -eq 'rest --method get' -and + ($_.Arguments -join ' ') -match '/sites/treemon\?api-version=' + } + )[-1] + $identitySummaryQueryIndex = + [Array]::IndexOf($identitySummaryCall.Arguments, '--query') + $identitySummaryQuery = + [string] $identitySummaryCall.Arguments[$identitySummaryQueryIndex + 1] + Assert-True ` + -Condition ($identitySummaryQueryIndex -ge 0 -and + $identitySummaryQuery -match 'userAssignedIdentityCount' -and + $identitySummaryQuery -match 'preparedIdentityAttached' -and + $identitySummaryQuery -notmatch '(?i)tenantId|/subscriptions/') ` + -Because 'the preflight may return only a sanitized attachment count and prepared-identity match' + $assignmentCall = @($script:azNoneCalls)[0] $identitiesIndex = [Array]::IndexOf($assignmentCall.Arguments, '--identities') Assert-Equal ` -Actual ([string] $assignmentCall.Arguments[$identitiesIndex + 1]) ` -Expected ([string] $script:correctionIdentity.id) ` - -Because 'the moved app identity set must be replaced with the prepared destination identity' + -Because 'the prepared destination identity must be attached after the preflight passes' Assert-True ` -Condition (($assignmentCall.Arguments -join ' ') -notmatch 'source-subscription|source-rg|source-identity') ` -Because 'the identity assignment must not carry a source resource identifier' } +Invoke-TestCase 'post-move reconciliation rejects a foreign identity before mutating the app' { + Reset-CorrectionMocks -Scenario 'correction-destination' + $script:correctionIdentityAttachmentSummary = + [pscustomobject]@{ + identityType = 'UserAssigned' + userAssignedIdentityCount = 1 + preparedIdentityAttached = $false + } + $message = '' + + try { + Set-CrossSubscriptionReplacementIdentity ` + -ManagedIdentity $script:correctionIdentity ` + -SubscriptionId $script:correctionSubscriptionId + } catch { + $message = $_.Exception.Message + } + + Assert-True ` + -Condition ($message -match 'detach every other user-assigned identity' -and + $message -match 'No App Service change was made') ` + -Because 'a foreign attachment must fail with actionable portal guidance' + Assert-Equal ` + -Actual $script:azNoneCalls.Count ` + -Expected 0 ` + -Because 'the stale attachment must be detected before identity assignment mutates the app' + Assert-Equal ` + -Actual $script:azJsonCalls.Count ` + -Expected 1 ` + -Because 'the rejection may perform only the sanitized destination-side attachment read' + + $summaryCall = $script:azJsonCalls[0] + $subscriptionIndex = + [Array]::IndexOf($summaryCall.Arguments, '--subscription') + Assert-Equal ` + -Actual ([string] $summaryCall.Arguments[$subscriptionIndex + 1]) ` + -Expected $script:correctionSubscriptionId ` + -Because 'the attachment preflight must stay within the approved destination' +} + Invoke-TestCase 'every Azure resource-plane call selects its subscription explicitly' { $resourceFamilies = @( From 6eb85f234be1513d15db2941dce1dfac21050733 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Thu, 20 Aug 2026 17:37:42 +0200 Subject: [PATCH 21/27] tm-canvas-safe-share-feo Permit private verification workflow reports Document the narrow response-text-only exception for redacted reports under gitignored .agents/verify/ written by bd-verify-executor and read by bd-verify-reviewer. --- AGENTS.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 688a2ecf..455ddc34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,18 @@ Before implementing a helper, utility, or any non-trivial logic, **search the co `pwsh.exe` PIDs whose decoded `-EncodedCommand` starts in that unique fixture path; never terminate the shared `WindowsTerminal.exe`/HWND-owner PID. +### Private verification reports + +- The response-text-only rule for findings has one narrow exception: when a beads task is labeled + `verify`, explicitly requires a report path under gitignored `.agents/verify/`, and the path is + confirmed ignored, `bd-verify-executor` may create or update that redacted report and + `bd-verify-reviewer` may read it. +- This report is a sanctioned private workflow artifact, not a tracked or shared findings dump. + The exception does not allow reports elsewhere, tracked result files, planning notes, general + review dumps, or unredacted secrets or private identifiers. +- Keep the caller-facing verification summary concise. All other output-channel constraints remain + in force. + ## Ports | Environment | Port | From eec99cf60a5d169d818644cf69f21666d3e6a986 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Mon, 24 Aug 2026 15:44:17 +0200 Subject: [PATCH 22/27] tm-canvas-safe-share-djv Reconcile the viewer after the manual portal move Record the browser-navigation health-probe contract: the post-move Entra redirect check sends browser navigation headers (Accept: text/html) so the probe exercises the same path a recipient's browser takes rather than an API-style client. Reconciliation itself was an Azure-side operation in the approved personal subscription only; post-move IDs, etags, timestamps, rollback snapshot, and PASS evidence stay in the ignored .agents/azure-migration/ manifest. --- docs/spec/canvas-sharing.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index 3902351b..dfedec60 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -434,9 +434,10 @@ remote-URL fetch that would otherwise be a working exfiltration channel. - The copied URL is clean: no SAS, signature, or other query-string token of any kind. - The deployed viewer and every returned share URL use `https://treemon.azurewebsites.net`; provisioning never substitutes a suffixed hostname. -- Entra redirect/allow/deny: an anonymous request redirects to sign-in; any identity the tenant - authenticates -- member or B2B guest -- views the document; an identity outside the tenant is - denied. +- Entra redirect/allow/deny: an anonymous browser navigation redirects to sign-in; the probe sends + browser navigation headers (`Accept: text/html`), since App Service Easy Auth may return an empty + 401 instead of a redirect to an API-style client. Any identity the tenant authenticates -- member + or B2B guest -- views the document; an identity outside the tenant is denied. - The control-plane RBAC audit finds no effective Blob data-plane read assignment outside the share container, and the live second-container probe under the deployed viewer identity still returns 403 as defense in depth. From e7096476a3df5081f0df776e966c64ebeadd7e74 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Wed, 26 Aug 2026 13:44:28 +0200 Subject: [PATCH 23/27] Minimize canvas viewer implementation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/canvas-share-viewer-deployment.md | 65 --- docs/spec/canvas-sharing.md | 141 +----- .../CrossSubscriptionCorrection.ps1 | 281 ----------- .../Deployment.Tests.ps1 | 474 +----------------- .../TestHarness.ps1 | 32 ++ .../ViewerBlobAccess.Tests.ps1 | 24 +- scripts/deploy-canvas-share-viewer.ps1 | 76 +-- src/CanvasShareViewer/ShareLookup.fs | 32 +- src/CanvasShareViewer/ViewerApplication.fs | 19 +- src/Tests/CanvasShareTests.fs | 15 - .../CanvasShareViewerContainmentTests.fs | 205 -------- src/Tests/CanvasShareViewerTests.fs | 5 + 12 files changed, 83 insertions(+), 1286 deletions(-) delete mode 100644 scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1 create mode 100644 scripts/canvas-share-viewer-deployment/TestHarness.ps1 diff --git a/docs/canvas-share-viewer-deployment.md b/docs/canvas-share-viewer-deployment.md index c9fb75e5..0488c0df 100644 --- a/docs/canvas-share-viewer-deployment.md +++ b/docs/canvas-share-viewer-deployment.md @@ -78,71 +78,6 @@ also resolves its direct and inherited role assignments and fails when any effec data action is scoped outside the configured share container. Group-derived assignments are included. It performs no Azure mutation and does not write machine configuration. -## Correct a deployment from another subscription - -The correction workflow never accepts a source subscription, tenant, resource group, identity, or -resource ID. Automation operates only in the machine-approved destination; the operator performs -the App Service move in the Azure portal. - -First prepare the destination: - -```powershell -.\scripts\deploy-canvas-share-viewer.ps1 ` - -Subscription '' ` - -Tenant '' ` - -ResourceGroup '' ` - -Plan '' ` - -Identity '' ` - -Registration '' ` - -PrepareCrossSubscriptionMove -``` - -Preparation confirms that the configured storage account and private share container already -exist in the approved subscription. It creates or reconciles only the destination resource group, -replacement user-assigned identity, and that identity's container-scoped `Storage Blob Data -Reader` assignment. It does not check global availability of `treemon`, create or read the App -Service or plan, mutate the app registration, change storage configuration, build or deploy the -viewer, or write Treemon configuration. It finishes by printing a redacted portal checklist. - -Follow that checklist in the Azure portal. First detach the App Service's current user-assigned -identity attachment and confirm that its system-assigned identity is off; leave the detached -identity resource, its role assignments, storage, and resource groups in place for rollback. Then -move the canonical `treemon` App Service and its App Service plan together into the prepared -destination. Do not substitute an ordinary `-ValidateOnly` or apply run for preparation; before -the portal move those modes correctly stop because the existing app still owns the global name. -The viewer is expected to be unavailable from detachment until reconciliation finishes. If the -portal move fails before the app reaches the destination, manually reattach the preserved former -identity before retrying or restoring service. - -After the portal reports success, use an isolated `TREEMON_CONFIG_DIR` seeded with the approved -subscription, storage account, and container from the private machine configuration, then run: - -```powershell -.\scripts\deploy-canvas-share-viewer.ps1 ` - -Subscription '' ` - -Tenant '' ` - -ResourceGroup '' ` - -Plan '' ` - -Identity '' ` - -Registration '' ` - -ReconcileCrossSubscriptionMove ` - -ConfirmPortalMoveCompleted -``` - -The confirmation switch is mandatory and is checked before local tools or Azure are consulted. -Reconciliation requires the moved canonical app, its plan, the prepared identity, and the retained -registration to exist. Before changing the app, it reads only a sanitized count and -prepared-identity match from the moved app's destination-side identity state. Any other -user-assigned attachment or a system-assigned identity stops reconciliation with portal guidance -and no App Service mutation; detach it in the portal and rerun. Once that preflight passes, -reconciliation attaches the prepared destination identity, then reconciles federation, -private-container RBAC, settings, lifecycle policy, package, Easy Auth, and deployed state through -approved-subscription-only calls. The isolated config receives the canonical `viewerBaseUrl`; the -production config remains unchanged. - -If the app must be moved back for rollback, move the app and plan together and manually reattach -the preserved former identity afterwards. - ## Provision and deploy Remove `-ValidateOnly` to apply the same plan: diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index 2de6d8fc..97362df9 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -123,7 +123,7 @@ Its value may be a subscription name or ID; the script resolves it to a subscription ID and compares IDs, so the two forms are interchangeable. - The deployment script and the server both resolve the config through `TREEMON_CONFIG_DIR` when - it is set. Migration reconciliation and live verification use that isolation deliberately: they + it is set. Deployment and live verification use that isolation deliberately: they run against a throwaway config seeded with `accountName`, `container`, and `approvedSubscription` copied from the private machine config, so a run cannot add `viewerBaseUrl` to -- or otherwise alter -- the configuration the production instance reads. @@ -243,111 +243,26 @@ remote-URL fetch that would otherwise be a working exfiltration channel. ### Provisioning -- Provisioning targets the personal development Azure subscription and an isolated, - non-production resource group. Subscription, tenant, resource-group, plan, identity, and - registration names are operator inputs; the App Service name is `treemon`, producing - `https://treemon.azurewebsites.net`. -- Machine-private configuration identifies the one approved subscription. Before any - resource-provider or Entra application operation, provisioning requires the operator input and - selected Azure CLI account to match that configured target exactly and fails with no changes when - the value is absent, ambiguous, disabled, or mismatched. Exact subscription and tenant - identifiers never appear in tracked repository content. - The match is by resolved subscription ID -- the configured value, the `-Subscription` input, and - the selected CLI account must all resolve to the same enabled subscription -- so a name and an ID - naming the same subscription agree, while a raw-string near-miss cannot pass. Failure diagnostics - name the configuration key and the mismatch, never the values. - The selected account resolves before either configured subscription value is looked up, so an - unusable Azure CLI sign-in keeps its own actionable authentication error instead of being - reported as a configuration problem. - The guard is mandatory and independent of whatever restrictions the operator's environment - places on direct Azure CLI use. An environment-level control can only see the commands issued to - it, not the `az` child processes this script spawns, so the script proves its own target on every - run; the two are layers, and neither is removed or relaxed because the other exists. Every - resource-plane `az` invocation the script makes names its subscription explicitly rather than - inheriting the CLI's selected account. -- Before first creation, provisioning checks global App Service name availability and fails - clearly if `treemon` is no longer available. It never silently appends a random suffix because - that would change the durable shared-link origin and its browser SSO session. -- The publisher keeps its existing delegated Entra/Azure CLI identity and - `Storage Blob Data Contributor` grant. The viewer uses a managed identity with the new read-only - grant scoped to the share container. -- When the requested viewer identity already exists, provisioning audits its Blob-read RBAC before - any Azure mutation and repeats the audit as part of deployed-state verification. The audit - enumerates direct and group-derived assignments throughout the subscription plus assignments - inherited from parent scopes, resolves each role definition's effective - `dataActions`/`notDataActions`, and fails on a Blob-read grant unless its assignment scope is the - configured share container or a descendant. It reports but never deletes an offending - assignment, because that grant may belong to another workload. -- Provisioning ensures the configured share container exists with anonymous access disabled before - either container-scoped grant is applied; the publisher intentionally does not create containers - at share time. -- `scripts/deploy-canvas-share-viewer.ps1` is the idempotent operator entry point. Its only - deployment-name inputs are subscription, tenant, resource group, plan, identity, and app - registration; it reads account/container from the machine-level `canvasShare` config and resolves - the delegated publisher as the current Azure CLI user. The fixed B1 Linux plan and any new - resource group use the storage account's Azure location. -- Azure CLI resource reads accept both flattened command output and fields nested under - `properties`, including the current `appServicePlanId` web-app field. The pipe-bearing Linux - runtime value is supplied through the CLI's JSON-file configuration path rather than as an - `az.cmd` argument. If Entra alone rejects an app-registration create or update because - `serviceManagementReference` is required, provisioning retries with the one distinct non-empty - reference on applications owned by the delegated publisher; zero or multiple values fail closed - rather than inventing or ambiguously selecting an organizational service reference. -- `-ValidateOnly` performs subscription/tenant, configuration, existing-resource, viewer-identity - RBAC, global-name, and local-publish checks without changing Azure or machine configuration. An - apply run reconciles resources, merges the canvas rule into the account's complete lifecycle - policy without removing unrelated rules, deploys with `az webapp deploy` after SCM/FTP basic - authentication is disabled, verifies the resulting control-plane state, and writes the exact - canonical `viewerBaseUrl` while preserving every other machine setting. -- Deployment sets and deployed-state validation asserts both `DOTNET_ENVIRONMENT=Production` and - `ASPNETCORE_ENVIRONMENT=Production`; the application-level dependency exception boundary remains - active even if either platform setting later drifts. -- Easy Auth's `clientSecretSettingName` is the - `OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID` sentinel; the slot-sticky app setting with that name - contains the user-assigned identity's client ID. The registration's federated credential trusts - that identity's principal ID with the tenant v2 issuer and `api://AzureADTokenExchange` - audience. Provisioning and deployed-state validation require ID-token issuance for Easy Auth's - hybrid callback while keeping browser access-token issuance disabled. No client secret or extra - login scope is created. -- The canonical App Service, identity, Entra configuration, RBAC grants, and lifecycle policy - remain deployed after verification. Verification removes only its document fixtures and any - auxiliary resources created solely to prove the permission boundary. -- A cross-subscription correction preserves the canonical hostname by moving the App Service and - its plan together rather than deleting and recreating the globally named app. The correction is - split by who can reach which subscription. `-PrepareCrossSubscriptionMove` confirms the - configured private storage/container and reconciles only the approved destination resource - group, replacement user-assigned identity, and its container-scoped reader grant; it neither - checks the global app name nor reads or creates an App Service or plan. - `-ReconcileCrossSubscriptionMove` requires `-ConfirmPortalMoveCompleted` before any tool or Azure - call, resolves the moved app and plan only in the approved destination, and checks a sanitized - destination-side summary of the app's identity attachments before changing the app. A - system-assigned identity or any user-assigned identity other than the prepared destination - identity fails with portal guidance and no App Service mutation. Once the check passes, - reconciliation attaches the prepared identity and reconciles the retained tenant-scoped app - registration, federated credential, container RBAC, settings, package, and deployed state. - Between those modes the redacted portal checklist has the operator detach the app's current - user-assigned identity attachment, leave that identity resource and its roles in place, then move - the app and plan together. Automation never queries, validates, or mutates anything in the source - subscription and never carries a resource ID, subscription, or tenant identifier belonging to - it; the checklist identifies what to detach and move by resource type and the canonical App - Service and plan names. - Source-side leftovers -- the obsolete identity, its role assignments, any stand-in verification - storage, and the former resource groups -- stay in place until the move is independently - verified. Identity detachment makes the viewer unavailable until reconciliation; a failed or - rolled-back move requires the operator to reattach the preserved former identity in the portal. - Removing those leftovers is a separate, explicitly approved operator step, not part of the move. - The source app still holds the global name until it moves, so a pre-move `-ValidateOnly` run - against the destination subscription is expected to stop at the name-availability check. That - check is not relaxed for the correction: the operator's move is what places the app in the - destination, and reconciliation runs afterwards, when the app is already there and the check no - longer applies. -- Decommissioning the source-side leftovers is a manual operator activity in the portal. Automation - prepares an ordered checklist describing each target by resource type and role -- never by - resource ID, subscription, or tenant identifier -- and reviews whatever redacted evidence the - operator supplies afterwards. It deletes nothing, at any scope, in either subscription. +- `scripts/deploy-canvas-share-viewer.ps1` is the idempotent validate/apply entry point. Deployment + names are operator inputs; storage and publisher identity come from machine configuration and the + selected Azure CLI user. Exact subscription, tenant, and resource identifiers remain private. +- The requested, approved, and selected subscriptions must resolve to the same enabled subscription + before any resource-provider or Entra operation. Every resource-plane call names that subscription + explicitly, and failures identify the protected setting without revealing its value. +- The App Service keeps the fixed `treemon.azurewebsites.net` name. First creation fails rather than + silently choosing another hostname, preserving shared-link origin and browser SSO. +- The delegated publisher retains Blob-contributor access. The viewer receives a separate managed + identity whose effective Blob-read access is audited before mutation and after deployment; any + read grant broader than the configured private container fails closed and is never auto-deleted. +- Apply reconciles the private container, lifecycle rule, B1 Linux plan, App Service, secret-free + Easy Auth federation, production environment settings, and canonical `viewerBaseUrl`. Validation + performs the same safety checks plus a local Release publish without changing Azure or config. +- The lifecycle policy is merged with unrelated account rules, and verification removes only its + temporary document fixtures. Canonical resources and access grants remain deployed. - Feature development, deployment, and verification never run a production lifecycle command (`treemon.ps1 deploy`/`start`/`stop`/`restart`) and never bind to or otherwise disturb the production instance on port 5000. +- See `docs/canvas-share-viewer-deployment.md` for prerequisites and operator commands. ## Security Posture @@ -396,11 +311,6 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | Derive storage and publisher deployment inputs from machine/Azure CLI state | The existing `canvasShare` account/container and current delegated publisher are already the publisher's source of truth. Requiring them again as script arguments would permit a viewer and publisher to be provisioned against different containers or identities. | | Require an exact machine-private subscription allowlist match | Azure CLI's ambient default proves only what is selected, not whether that subscription is approved for this workload. A private source of truth keeps identifiers out of the repository and makes a mistaken shared-subscription deployment fail before any resource operation. | | Keep the in-script guard even when the environment already restricts direct CLI use | A control that filters issued commands cannot observe the `az` child processes a deployment script starts, so it stops covering exactly the operations this feature automates. The script's own check is the only one present inside a run, and an outer restriction is never accepted as a reason to remove or weaken it. | -| Move the App Service and plan together when correcting subscription placement | An ARM move preserves the globally unique `treemon.azurewebsites.net` name; deleting and recreating the app would briefly release that name and make rollback dependent on reacquiring it. | -| Have the operator perform the move in the portal rather than automating it | Automation is confined to the approved subscription and cannot query or validate the source one, so it cannot issue the move at all. Splitting the correction into prepare / operator move / reconcile keeps the one irreversible step under direct human control and leaves automation with only operations it is allowed to perform. | -| Refuse a foreign app identity before post-move mutation | Removing a stale user-assigned identity through Azure CLI requires naming that identity's resource ID, which would carry a source-subscription identifier into automation. The portal checklist detaches the app attachment before the move; reconciliation reads only a sanitized destination-side count and prepared-identity match, fails before mutation if another attachment remains, and stays idempotent when only the prepared identity is already attached. | -| Expose preparation and reconciliation as mutually exclusive deployment-script modes | Preparation must bypass the ordinary global-name guard without weakening it, while reconciliation must not begin from an unconfirmed portal handoff. Separate parameter sets and an explicit post-move confirmation make those safety boundaries visible at invocation time. | -| Decommission by exact resource allowlist rather than broad scope | Shared subscriptions can contain unrelated workloads. Fresh inventory, drift checks, and individual resource-ID deletion make collateral changes falsifiable and leave ambiguous resources untouched. | | Reuse one unambiguous publisher-owned `serviceManagementReference` only when Entra requires it | Restricted tenants reject registration mutations without their organizational service reference, while an arbitrary GUID can be invalid or misrepresent ownership. Conditional discovery keeps the normal path unchanged, adds no secret or deployment-name input, and fails closed when publisher-owned state cannot identify one value. | | Pass the Linux runtime through Azure CLI's JSON-file configuration input | The runtime contains `|`, which the Windows `az.cmd` launcher can reinterpret as a command pipe even when PowerShell supplied it as one argument. A file preserves the exact value without platform-specific quoting or reliance on Azure CLI installation internals. | | Treat only a container-scoped RBAC assignment (or a descendant scope) as proof of viewer containment | Fully interpreting arbitrary Azure RBAC conditions would reproduce the authorization engine and could silently accept a broader grant. A conditioned assignment at an account, resource-group, subscription, or parent scope therefore fails closed; the operator must remove it or use a dedicated identity. | @@ -426,8 +336,7 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | `scripts/deploy-canvas-share-viewer.ps1` | Idempotent non-production Azure provisioning, secret-free Easy Auth, Entra-authenticated ZIP deployment, validation, and machine-config update | | `scripts/canvas-share-viewer-deployment/Common.ps1` | Shared Azure CLI invocation and machine-configuration boundary for deployment helpers | | `scripts/canvas-share-viewer-deployment/SubscriptionGuard.ps1` | Fail-closed approved/requested/selected subscription, tenant, and delegated-publisher validation with private lookup diagnostics redacted | -| `scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1` | Approved-destination preparation, redacted identity-detachment/move handoff, sanitized post-move identity preflight, and prepared-identity attachment | -| `scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1` | Windows/Azure CLI shape, packaging-output, reconciliation, restricted-tenant registration, and Easy Auth callback regressions | +| `scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1` | Windows/Azure CLI shape, packaging-output, restricted-tenant registration, and Easy Auth callback regressions | | `scripts/canvas-share-viewer-deployment/ViewerBlobAccess.ps1` | Fail-closed audit of the viewer identity's effective Blob-read data-plane assignments | | `scripts/canvas-share-lifecycle-policy.json` | Container-filtered deletion rule starting after 31 days | | `docs/canvas-share-viewer-deployment.md` | Local operator prerequisites, dry run, apply, and durable-resource guidance | @@ -465,18 +374,6 @@ remote-URL fetch that would otherwise be a working exfiltration channel. - Deployment against a subscription other than the machine-private approved one exits non-zero before any resource-provider or Entra application call, and its output contains no exact subscription name or ID; the approved context proceeds normally. -- A moved app that still reports a foreign user-assigned identity fails reconciliation before any - App Service mutation, exposes no attached identity ID, and tells the operator to detach it in the - portal before retrying. -- After the cross-subscription correction the App Service and its plan are in the approved - subscription, still answer on `https://treemon.azurewebsites.net`, and the authenticated share - lifecycle -- publish, view, expiry, revocation, containment, fixed 503 -- still passes end to - end when driven through an isolated `TREEMON_CONFIG_DIR` on a port other than 5000, leaving the - production configuration and instance untouched. -- Nothing in the approved subscription outside the prepared destination and the reconciled app - changed, and the correction's own command log shows no operation against the source - subscription and no resource ID belonging to it. Source-side state is attested by the operator, - not queried by automation. ## Related Specs diff --git a/scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1 b/scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1 deleted file mode 100644 index 131fa2ab..00000000 --- a/scripts/canvas-share-viewer-deployment/CrossSubscriptionCorrection.ps1 +++ /dev/null @@ -1,281 +0,0 @@ -function Assert-CrossSubscriptionCorrectionParameters { - param( - [switch] $ReconcileCrossSubscriptionMove, - [switch] $ConfirmPortalMoveCompleted - ) - - if ($ReconcileCrossSubscriptionMove -and -not $ConfirmPortalMoveCompleted) { - throw 'Post-move reconciliation requires -ConfirmPortalMoveCompleted after the operator has verified that the portal move succeeded.' - } -} - -function Assert-CorrectionResourceGroupSafety { - param([AllowNull()][pscustomobject] $ExistingGroup) - - if ($null -eq $ExistingGroup) { - return - } - - $environmentTag = - if ($null -ne $ExistingGroup.tags -and - $null -ne $ExistingGroup.tags.PSObject.Properties['environment']) { - [string] $ExistingGroup.tags.PSObject.Properties['environment'].Value - } else { - '' - } - - if ($environmentTag -match '^(?i:prod|production)$') { - throw "Resource group '$ResourceGroup' is tagged as production. This automation is non-production only." - } -} - -function Assert-ConfiguredPrivateShareContainer { - param( - [Parameter(Mandatory)][pscustomobject] $StorageAccount, - [Parameter(Mandatory)][string] $Container, - [Parameter(Mandatory)][string] $SubscriptionId - ) - - if ([bool] (Get-AzureResourcePropertyValue ` - -Resource $StorageAccount ` - -Name 'allowBlobPublicAccess')) { - throw 'The configured storage account permits public Blob access. Destination preparation will not change storage configuration.' - } - - $existingContainer = Try-AzJson -Arguments @( - 'storage', 'container-rm', 'show', - '--storage-account', [string] $StorageAccount.id, - '--name', $Container, - '--subscription', $SubscriptionId) - - if ($null -eq $existingContainer) { - throw "Configured Blob container '$Container' was not found in the approved subscription. Destination preparation will not create it." - } - - $publicAccess = - [string] (Get-AzureResourcePropertyValue ` - -Resource $existingContainer ` - -Name 'publicAccess') - - if (-not [string]::IsNullOrWhiteSpace($publicAccess) -and - $publicAccess -notmatch '^(?i:none|off)$') { - throw "Configured Blob container '$Container' permits public access. Destination preparation will not change it." - } -} - -function Get-CrossSubscriptionPreparationResources { - param([Parameter(Mandatory)][string] $SubscriptionId) - - $group = Try-AzJson -Arguments @( - 'group', 'show', - '--name', $ResourceGroup, - '--subscription', $SubscriptionId) - $identityResource = - if ($null -eq $group) { - $null - } else { - Try-AzJson -Arguments @( - 'identity', 'show', - '--name', $Identity, - '--resource-group', $ResourceGroup, - '--subscription', $SubscriptionId) - } - - [pscustomobject]@{ - Group = $group - Identity = $identityResource - } -} - -function Get-CrossSubscriptionMoveDestinationResources { - param([Parameter(Mandatory)][string] $SubscriptionId) - - $preparationResources = - Get-CrossSubscriptionPreparationResources -SubscriptionId $SubscriptionId - $planResource = - if ($null -eq $preparationResources.Group) { - $null - } else { - Try-AzJson -Arguments @( - 'appservice', 'plan', 'show', - '--name', $Plan, - '--resource-group', $ResourceGroup, - '--subscription', $SubscriptionId) - } - $webApp = - if ($null -eq $preparationResources.Group) { - $null - } else { - Try-AzJson -Arguments @( - 'rest', - '--method', 'get', - '--uri', "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Web/sites/${appName}?api-version=2023-12-01", - '--query', '{id:id,name:name,defaultHostName:properties.defaultHostName,kind:kind,appServicePlanId:properties.serverFarmId,httpsOnly:properties.httpsOnly}', - '--subscription', $SubscriptionId) - } - - [pscustomobject]@{ - Group = $preparationResources.Group - Plan = $planResource - Identity = $preparationResources.Identity - WebApp = $webApp - Registration = Get-ExactAppRegistration - } -} - -function Assert-CrossSubscriptionMovedResources { - param( - [Parameter(Mandatory)][pscustomobject] $Existing, - [Parameter(Mandatory)][string] $SubscriptionId - ) - - if ($null -eq $Existing.Group) { - throw 'The prepared destination resource group was not found in the approved subscription.' - } - - Assert-CorrectionResourceGroupSafety -ExistingGroup $Existing.Group - - if ($null -eq $Existing.Plan) { - throw "The moved App Service plan '$Plan' was not found in the prepared destination." - } - - if (-not [bool] (Get-AzureResourcePropertyValue ` - -Resource $Existing.Plan ` - -Name 'reserved')) { - throw "Moved App Service plan '$Plan' is not a Linux plan." - } - - if ($null -eq $Existing.Identity) { - throw "Replacement user-assigned identity '$Identity' was not found in the prepared destination." - } - - if ($null -eq $Existing.WebApp) { - throw "The canonical App Service '$appName' was not found in the prepared destination. Confirm the portal move before reconciliation." - } - - if ([string] $Existing.WebApp.defaultHostName -cne 'treemon.azurewebsites.net' -or - [string] $Existing.WebApp.kind -notmatch '(^|,)linux($|,)' -or - -not [string]::Equals( - (Get-WebAppPlanResourceId -WebApp $Existing.WebApp), - [string] $Existing.Plan.id, - [StringComparison]::OrdinalIgnoreCase)) { - throw 'The moved App Service does not match the canonical hostname, Linux kind, and requested destination plan.' - } - - if ($null -eq $Existing.Registration) { - throw "The retained Entra app registration '$Registration' was not found." - } - - Assert-RegistrationIsDedicated -AppRegistration $Existing.Registration - Assert-NoClientSecretConfiguration -SubscriptionId $SubscriptionId -} - -function Set-CrossSubscriptionReplacementIdentity { - param( - [Parameter(Mandatory)][pscustomobject] $ManagedIdentity, - [Parameter(Mandatory)][string] $SubscriptionId - ) - - $preparedClientId = [string] $ManagedIdentity.clientId - if ([string]::IsNullOrWhiteSpace($preparedClientId)) { - throw 'The prepared destination identity has no client ID. No App Service change was made.' - } - - $preparedClientIdLiteral = - ConvertTo-Json -InputObject $preparedClientId -Compress - $identitySummaryQuery = - '{{identityType:identity.type,userAssignedIdentityCount:length(values(identity.userAssignedIdentities || `{{}}`)),preparedIdentityAttached:contains(values(identity.userAssignedIdentities || `{{}}`)[].clientId, `{0}`)}}' ` - -f $preparedClientIdLiteral - $identitySummary = Invoke-AzJson -Arguments @( - 'rest', - '--method', 'get', - '--uri', "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Web/sites/${appName}?api-version=2023-12-01", - '--query', $identitySummaryQuery, - '--subscription', $SubscriptionId) - - if ($null -eq $identitySummary -or - $null -eq $identitySummary.PSObject.Properties['identityType'] -or - $null -eq $identitySummary.PSObject.Properties['userAssignedIdentityCount'] -or - $null -eq $identitySummary.PSObject.Properties['preparedIdentityAttached']) { - throw 'The moved App Service identity attachment could not be verified. No App Service change was made.' - } - - $expectedIdentityCount = - if ([bool] $identitySummary.preparedIdentityAttached) { - 1 - } else { - 0 - } - - if ([string] $identitySummary.identityType -match 'SystemAssigned' -or - [int] $identitySummary.userAssignedIdentityCount -ne $expectedIdentityCount) { - throw 'The moved App Service still has an identity attachment other than the prepared destination identity. In the Azure portal, detach every other user-assigned identity and turn off its system-assigned identity, then rerun reconciliation. No App Service change was made.' - } - - Write-Step 'Attaching the prepared identity to the moved App Service' - Invoke-AzNone -Arguments @( - 'webapp', 'identity', 'assign', - '--name', $appName, - '--resource-group', $ResourceGroup, - '--identities', [string] $ManagedIdentity.id, - '--subscription', $SubscriptionId) -} - -function Write-CrossSubscriptionMoveChecklist { - Write-Host '' - Write-Host 'Destination preparation complete. No source-subscription operation was issued.' - Write-Host "1. In the Azure portal, open the canonical App Service '$appName', detach its user-assigned identity attachment, and confirm that System assigned is Off." - Write-Host '2. Leave the detached identity resource, role assignments, storage, and resource groups unchanged for rollback.' - Write-Host "3. Move the canonical App Service '$appName' and App Service plan '$Plan' together." - Write-Host '4. Select the approved destination subscription and the prepared destination resource group used for this run.' - Write-Host '5. Wait for the portal move to succeed, seed an isolated TREEMON_CONFIG_DIR, then rerun the same command with -ReconcileCrossSubscriptionMove -ConfirmPortalMoveCompleted.' - Write-Host 'The viewer is expected to be unavailable after identity detachment until reconciliation finishes. If the move fails, reattach the preserved identity in the portal before retrying.' - Write-Host 'The automation does not accept or print a source subscription, tenant, resource group, or resource ID.' -} - -function Invoke-CrossSubscriptionMovePreparation { - param( - [Parameter(Mandatory)][pscustomobject] $StorageAccount, - [Parameter(Mandatory)][string] $Container, - [Parameter(Mandatory)][string] $ContainerScope, - [Parameter(Mandatory)][string] $SubscriptionId - ) - - Write-Step 'Confirming the configured private destination storage' - Assert-ConfiguredPrivateShareContainer ` - -StorageAccount $StorageAccount ` - -Container $Container ` - -SubscriptionId $SubscriptionId - - $existing = - Get-CrossSubscriptionPreparationResources -SubscriptionId $SubscriptionId - Assert-CorrectionResourceGroupSafety -ExistingGroup $existing.Group - - if ($null -ne $existing.Identity) { - Assert-ViewerBlobAccessIsContainerOnly ` - -PrincipalObjectId ([string] $existing.Identity.principalId) ` - -ContainerScope $ContainerScope ` - -SubscriptionId $SubscriptionId - } - - $group = Ensure-ResourceGroup ` - -ExistingGroup $existing.Group ` - -StorageAccount $StorageAccount ` - -SubscriptionId $SubscriptionId - $managedIdentity = Ensure-ManagedIdentity ` - -ExistingIdentity $existing.Identity ` - -Location ([string] $group.location) ` - -SubscriptionId $SubscriptionId - Ensure-RoleAssignment ` - -PrincipalObjectId ([string] $managedIdentity.principalId) ` - -PrincipalType ServicePrincipal ` - -Role $readerRole ` - -Scope $ContainerScope ` - -SubscriptionId $SubscriptionId - Assert-ViewerBlobAccessIsContainerOnly ` - -PrincipalObjectId ([string] $managedIdentity.principalId) ` - -ContainerScope $ContainerScope ` - -SubscriptionId $SubscriptionId - - Write-CrossSubscriptionMoveChecklist -} diff --git a/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 b/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 index 08d0155b..e41880cc 100644 --- a/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 +++ b/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 @@ -3,6 +3,8 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'TestHarness.ps1') + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) $viewerProject = Join-Path $repoRoot 'src' 'CanvasShareViewer' 'CanvasShareViewer.fsproj' $lifecyclePolicyPath = Join-Path $repoRoot 'scripts' 'canvas-share-lifecycle-policy.json' @@ -12,39 +14,6 @@ $viewerBaseUrl = 'https://treemon.azurewebsites.net' . (Join-Path $PSScriptRoot 'Common.ps1') . (Join-Path $PSScriptRoot 'SubscriptionGuard.ps1') -function Assert-Equal { - param( - [AllowNull()][object] $Actual, - [AllowNull()][object] $Expected, - [Parameter(Mandatory)][string] $Because - ) - - if ($Actual -ne $Expected) { - throw "Expected '$Expected' but got '$Actual': $Because" - } -} - -function Assert-True { - param( - [Parameter(Mandatory)][bool] $Condition, - [Parameter(Mandatory)][string] $Because - ) - - if (-not $Condition) { - throw "Expected true: $Because" - } -} - -function Invoke-TestCase { - param( - [Parameter(Mandatory)][string] $Name, - [Parameter(Mandatory)][scriptblock] $Body - ) - - & $Body - Write-Host "PASS: $Name" -} - Invoke-TestCase 'deployment entry point loads the subscription guard after Common and before Azure' { $entryPoint = [IO.File]::ReadAllText( @@ -117,13 +86,10 @@ $Identity = 'viewer-identity' $Registration = 'viewer-registration' . (Join-Path $PSScriptRoot 'Azure.ps1') -. (Join-Path $PSScriptRoot 'CrossSubscriptionCorrection.ps1') $script:scenario = '' $script:azNoneCalls = @() $script:azJsonCalls = @() -$script:azTryCalls = @() -$script:blobAccessAudits = @() $script:subscriptionAccounts = @{} $script:subscriptionLookupFailures = @{} $script:selectedAccount = $null @@ -134,50 +100,6 @@ $script:nameAvailabilityCallCount = 0 $script:serviceManagementReference = '11111111-2222-3333-4444-555555555555' $script:planResourceId = '/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/viewer-rg/providers/Microsoft.Web/serverfarms/viewer-plan' -$script:correctionSubscriptionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' -$script:correctionStorageAccount = - [pscustomobject]@{ - id = "/subscriptions/$script:correctionSubscriptionId/resourceGroups/storage-rg/providers/Microsoft.Storage/storageAccounts/fixturestorage" - name = 'fixturestorage' - location = 'westeurope' - resourceGroup = 'storage-rg' - allowBlobPublicAccess = $false - } -$script:correctionGroup = - [pscustomobject]@{ - id = "/subscriptions/$script:correctionSubscriptionId/resourceGroups/$ResourceGroup" - name = $ResourceGroup - location = 'westeurope' - tags = [pscustomobject]@{ environment = 'nonproduction' } - } -$script:correctionIdentity = - [pscustomobject]@{ - id = "/subscriptions/$script:correctionSubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.ManagedIdentity/userAssignedIdentities/$Identity" - name = $Identity - principalId = '22222222-2222-2222-2222-222222222222' - clientId = '33333333-3333-3333-3333-333333333333' - } -$script:correctionPlan = - [pscustomobject]@{ - id = $script:planResourceId - name = $Plan - reserved = $true - } -$script:correctionWebApp = - [pscustomobject]@{ - id = "/subscriptions/$script:correctionSubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Web/sites/$appName" - name = $appName - defaultHostName = 'treemon.azurewebsites.net' - kind = 'app,linux' - appServicePlanId = $script:planResourceId - httpsOnly = $true - } -$script:correctionIdentityAttachmentSummary = - [pscustomobject]@{ - identityType = $null - userAssignedIdentityCount = 0 - preparedIdentityAttached = $false - } $script:webAppResult = [pscustomobject]@{ appServicePlanId = $script:planResourceId @@ -263,57 +185,6 @@ function Invoke-AzJson { throw 'Unexpected Azure CLI command in global-name-availability test.' } - if ($script:scenario -eq 'correction-preparation') { - if ($Arguments[0] -eq 'rest' -and - ($Arguments -join ' ') -match 'checknameavailability') { - $script:nameAvailabilityCallCount++ - return [pscustomobject]@{ nameAvailable = $false } - } - - $command = $Arguments[0..2] -join ' ' - - switch ($command) { - 'group show --name' { - return $script:correctionGroup - } - 'identity show --name' { - return $script:correctionIdentity - } - 'role assignment list' { - return @() - } - default { - throw 'Unexpected Azure CLI command in correction-preparation test.' - } - } - } - - if ($script:scenario -eq 'correction-destination') { - $command = $Arguments[0..2] -join ' ' - - switch ($command) { - 'rest --method get' { - if (($Arguments -join ' ') -match '/sites/treemon\?api-version=') { - return $script:correctionIdentityAttachmentSummary - } - - throw 'Unexpected destination REST read in correction test.' - } - 'ad app list' { - return @($script:registrationResult) - } - 'ad app show' { - return $script:registrationResult - } - 'webapp config appsettings' { - return @() - } - default { - throw 'Unexpected Azure CLI command in correction-destination test.' - } - } - } - $command = $Arguments[0..2] -join ' ' switch ($script:scenario) { @@ -357,69 +228,6 @@ function Invoke-AzJson { throw "Unexpected mocked Azure CLI command: $($Arguments -join ' ')" } -function Try-AzJson { - param([Parameter(Mandatory)][string[]] $Arguments) - - $script:azTryCalls += - [pscustomobject]@{ Arguments = @($Arguments) } - $command = $Arguments[0..2] -join ' ' - - if ($script:scenario -eq 'correction-preparation') { - switch ($command) { - 'storage container-rm show' { - return [pscustomobject]@{ publicAccess = 'None' } - } - 'group show --name' { - return $null - } - default { - throw 'Unexpected optional Azure CLI command in correction-preparation test.' - } - } - } - - if ($script:scenario -eq 'correction-destination') { - switch ($command) { - 'group show --name' { - return $script:correctionGroup - } - 'identity show --name' { - return $script:correctionIdentity - } - 'appservice plan show' { - return $script:correctionPlan - } - 'rest --method get' { - if (($Arguments -join ' ') -match '/sites/treemon\?api-version=') { - return $script:correctionWebApp - } - - return $null - } - default { - throw 'Unexpected optional Azure CLI command in correction-destination test.' - } - } - } - - throw "Unexpected mocked optional Azure CLI command: $($Arguments -join ' ')" -} - -function Assert-ViewerBlobAccessIsContainerOnly { - param( - [Parameter(Mandatory)][string] $PrincipalObjectId, - [Parameter(Mandatory)][string] $ContainerScope, - [Parameter(Mandatory)][string] $SubscriptionId - ) - - $script:blobAccessAudits += - [pscustomobject]@{ - PrincipalObjectId = $PrincipalObjectId - ContainerScope = $ContainerScope - SubscriptionId = $SubscriptionId - } -} - function Reset-AzureContextMocks { $script:scenario = 'azure-context' $script:azJsonCalls = @() @@ -433,21 +241,13 @@ function Reset-AzureContextMocks { } } -function Reset-CorrectionMocks { +function Reset-ResourceMocks { param([Parameter(Mandatory)][string] $Scenario) $script:scenario = $Scenario $script:azNoneCalls = @() $script:azJsonCalls = @() - $script:azTryCalls = @() - $script:blobAccessAudits = @() $script:nameAvailabilityCallCount = 0 - $script:correctionIdentityAttachmentSummary = - [pscustomobject]@{ - identityType = $null - userAssignedIdentityCount = 0 - preparedIdentityAttached = $false - } } function New-SyntheticAccount { @@ -790,115 +590,8 @@ Invoke-TestCase 'approved subscription context proceeds to publisher lookup' { -Because 'publisher lookup may proceed only after every subscription identity agrees' } -Invoke-TestCase 'pre-move preparation bypasses the unavailable global app name safely' { - Reset-CorrectionMocks -Scenario 'correction-preparation' - $container = 'fixture-container' - $containerScope = - "$($script:correctionStorageAccount.id)/blobServices/default/containers/$container" - $output = @( - & { - Invoke-CrossSubscriptionMovePreparation ` - -StorageAccount $script:correctionStorageAccount ` - -Container $container ` - -ContainerScope $containerScope ` - -SubscriptionId $script:correctionSubscriptionId - } 6>&1 - ) | ForEach-Object { [string] $_ } - $outputText = $output -join [Environment]::NewLine - $mutatingCommands = - @( - $script:azNoneCalls - | ForEach-Object { $_.Arguments[0..2] -join ' ' } - ) - - Assert-Equal ` - -Actual $script:nameAvailabilityCallCount ` - -Expected 0 ` - -Because 'pre-move preparation must not check or weaken global App Service name availability' - Assert-Equal ` - -Actual $script:azNoneCalls.Count ` - -Expected 3 ` - -Because 'preparation may create only the destination group, replacement identity, and reader grant' - Assert-True ` - -Condition ($mutatingCommands -contains 'group create --name') ` - -Because 'the approved destination resource group must be prepared' - Assert-True ` - -Condition ($mutatingCommands -contains 'identity create --name') ` - -Because 'the replacement user-assigned identity must be prepared' - Assert-True ` - -Condition ($mutatingCommands -contains 'role assignment create') ` - -Because 'the replacement identity must receive its container-scoped reader grant' - Assert-True ` - -Condition (-not ($mutatingCommands | Where-Object { - $_ -match '^(appservice|storage|webapp|ad)\b' - })) ` - -Because 'preparation must not mutate the plan, app, storage, or Entra registration' - $wrongSubscriptionCalls = - @( - @($script:azJsonCalls) + @($script:azTryCalls) + @($script:azNoneCalls) - | Where-Object { - $subscriptionIndex = - [Array]::IndexOf($_.Arguments, '--subscription') - $subscriptionIndex -ge 0 -and - [string] $_.Arguments[$subscriptionIndex + 1] -cne - $script:correctionSubscriptionId - } - ) - Assert-Equal ` - -Actual $wrongSubscriptionCalls.Count ` - -Expected 0 ` - -Because 'preparation must issue every resource call only to the approved destination' - - $roleAssignmentCall = - @( - $script:azNoneCalls - | Where-Object { - ($_.Arguments[0..2] -join ' ') -eq 'role assignment create' - } - )[0] - Assert-True ` - -Condition ($roleAssignmentCall.Arguments -contains $readerRole -and - $roleAssignmentCall.Arguments -notcontains $contributorRole) ` - -Because 'preparation grants only Storage Blob Data Reader to the replacement identity' - Assert-Equal ` - -Actual $script:blobAccessAudits.Count ` - -Expected 1 ` - -Because 'the newly prepared identity must be audited after its reader grant' - Assert-True ` - -Condition ($outputText -match 'Destination preparation complete' -and - $outputText -match 'detach its user-assigned identity attachment' -and - $outputText -match 'ConfirmPortalMoveCompleted') ` - -Because 'preparation must emit the identity-detachment handoff and the explicit reconciliation command' - Assert-True ` - -Condition ($outputText -notmatch '(?i)/subscriptions/|tenant-id|source-rg|source-identity|[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}') ` - -Because 'the emitted checklist must not contain source subscription, tenant, group, identity, or resource IDs' - - $entrypoint = - [IO.File]::ReadAllText( - (Join-Path (Split-Path -Parent $PSScriptRoot) 'deploy-canvas-share-viewer.ps1')) - $preparationIndex = - $entrypoint.IndexOf( - 'Invoke-CrossSubscriptionMovePreparation', - [StringComparison]::Ordinal) - $preparationReturnIndex = - $entrypoint.IndexOf( - 'return', - $preparationIndex, - [StringComparison]::Ordinal) - $ordinaryDiscoveryIndex = - $entrypoint.IndexOf( - '$existingResources =', - $preparationIndex, - [StringComparison]::Ordinal) - Assert-True ` - -Condition ($preparationIndex -ge 0 -and - $preparationReturnIndex -gt $preparationIndex -and - $preparationReturnIndex -lt $ordinaryDiscoveryIndex) ` - -Because 'the entry point must return from preparation before ordinary app discovery and its global-name guard' -} - Invoke-TestCase 'ordinary creation and ValidateOnly retain the global-name failure' { - Reset-CorrectionMocks -Scenario 'global-name-unavailable' + Reset-ResourceMocks -Scenario 'global-name-unavailable' $existing = [pscustomobject]@{ Group = $null @@ -914,7 +607,7 @@ Invoke-TestCase 'ordinary creation and ValidateOnly retain the global-name failu try { Assert-ExistingResourceSafety ` -Existing $existing ` - -SubscriptionId $script:correctionSubscriptionId + -SubscriptionId $approvedSubscriptionId '' } catch { $_.Exception.Message @@ -946,162 +639,6 @@ Invoke-TestCase 'ordinary creation and ValidateOnly retain the global-name failu -Because 'ordinary validation must run the existing-resource safety guard before its read-only success exit' } -Invoke-TestCase 'post-move reconciliation requires explicit operator confirmation before tools run' { - Reset-CorrectionMocks -Scenario 'confirmation-gate' - $message = '' - - try { - Assert-CrossSubscriptionCorrectionParameters ` - -ReconcileCrossSubscriptionMove - } catch { - $message = $_.Exception.Message - } - - Assert-True ` - -Condition ($message -match 'ConfirmPortalMoveCompleted') ` - -Because 'reconciliation must name the explicit confirmation switch when it is absent' - Assert-Equal ` - -Actual ($script:azJsonCalls.Count + $script:azTryCalls.Count + $script:azNoneCalls.Count) ` - -Expected 0 ` - -Because 'the confirmation gate must not perform any Azure operation' - - Assert-CrossSubscriptionCorrectionParameters ` - -ReconcileCrossSubscriptionMove ` - -ConfirmPortalMoveCompleted - - $entrypoint = - [IO.File]::ReadAllText( - (Join-Path (Split-Path -Parent $PSScriptRoot) 'deploy-canvas-share-viewer.ps1')) - Assert-True ` - -Condition ($entrypoint.IndexOf( - 'Assert-CrossSubscriptionCorrectionParameters', - [StringComparison]::Ordinal) -lt - $entrypoint.IndexOf( - 'Assert-Prerequisites', - [StringComparison]::Ordinal)) ` - -Because 'the entry point must enforce confirmation before local tools or Azure are consulted' -} - -Invoke-TestCase 'post-move discovery and identity attachment use approved destination state only' { - Reset-CorrectionMocks -Scenario 'correction-destination' - $resources = - Get-CrossSubscriptionMoveDestinationResources ` - -SubscriptionId $script:correctionSubscriptionId - Assert-CrossSubscriptionMovedResources ` - -Existing $resources ` - -SubscriptionId $script:correctionSubscriptionId - - $resourceCalls = @($script:azTryCalls) - $unguardedCalls = - @( - $resourceCalls - | Where-Object { - $subscriptionIndex = - [Array]::IndexOf($_.Arguments, '--subscription') - $subscriptionIndex -lt 0 -or - [string] $_.Arguments[$subscriptionIndex + 1] -cne - $script:correctionSubscriptionId - } - ) - Assert-Equal ` - -Actual $unguardedCalls.Count ` - -Expected 0 ` - -Because 'post-move resource discovery must be scoped to the approved destination subscription' - - $webAppCall = - @( - $resourceCalls - | Where-Object { - ($_.Arguments[0..2] -join ' ') -eq 'rest --method get' -and - ($_.Arguments -join ' ') -match '/sites/treemon\?api-version=' - } - )[0] - $queryIndex = [Array]::IndexOf($webAppCall.Arguments, '--query') - Assert-True ` - -Condition ($queryIndex -ge 0 -and - [string] $webAppCall.Arguments[$queryIndex + 1] -notmatch '(?i)identity') ` - -Because 'initial destination discovery must not retrieve a source identity attachment' - Assert-Equal ` - -Actual $script:nameAvailabilityCallCount ` - -Expected 0 ` - -Because 'post-move discovery resolves the moved app and never checks global name availability' - - $script:azNoneCalls = @() - - Set-CrossSubscriptionReplacementIdentity ` - -ManagedIdentity $script:correctionIdentity ` - -SubscriptionId $script:correctionSubscriptionId - - $identitySummaryCall = - @( - $script:azJsonCalls - | Where-Object { - ($_.Arguments[0..2] -join ' ') -eq 'rest --method get' -and - ($_.Arguments -join ' ') -match '/sites/treemon\?api-version=' - } - )[-1] - $identitySummaryQueryIndex = - [Array]::IndexOf($identitySummaryCall.Arguments, '--query') - $identitySummaryQuery = - [string] $identitySummaryCall.Arguments[$identitySummaryQueryIndex + 1] - Assert-True ` - -Condition ($identitySummaryQueryIndex -ge 0 -and - $identitySummaryQuery -match 'userAssignedIdentityCount' -and - $identitySummaryQuery -match 'preparedIdentityAttached' -and - $identitySummaryQuery -notmatch '(?i)tenantId|/subscriptions/') ` - -Because 'the preflight may return only a sanitized attachment count and prepared-identity match' - - $assignmentCall = @($script:azNoneCalls)[0] - $identitiesIndex = [Array]::IndexOf($assignmentCall.Arguments, '--identities') - Assert-Equal ` - -Actual ([string] $assignmentCall.Arguments[$identitiesIndex + 1]) ` - -Expected ([string] $script:correctionIdentity.id) ` - -Because 'the prepared destination identity must be attached after the preflight passes' - Assert-True ` - -Condition (($assignmentCall.Arguments -join ' ') -notmatch 'source-subscription|source-rg|source-identity') ` - -Because 'the identity assignment must not carry a source resource identifier' -} - -Invoke-TestCase 'post-move reconciliation rejects a foreign identity before mutating the app' { - Reset-CorrectionMocks -Scenario 'correction-destination' - $script:correctionIdentityAttachmentSummary = - [pscustomobject]@{ - identityType = 'UserAssigned' - userAssignedIdentityCount = 1 - preparedIdentityAttached = $false - } - $message = '' - - try { - Set-CrossSubscriptionReplacementIdentity ` - -ManagedIdentity $script:correctionIdentity ` - -SubscriptionId $script:correctionSubscriptionId - } catch { - $message = $_.Exception.Message - } - - Assert-True ` - -Condition ($message -match 'detach every other user-assigned identity' -and - $message -match 'No App Service change was made') ` - -Because 'a foreign attachment must fail with actionable portal guidance' - Assert-Equal ` - -Actual $script:azNoneCalls.Count ` - -Expected 0 ` - -Because 'the stale attachment must be detected before identity assignment mutates the app' - Assert-Equal ` - -Actual $script:azJsonCalls.Count ` - -Expected 1 ` - -Because 'the rejection may perform only the sanitized destination-side attachment read' - - $summaryCall = $script:azJsonCalls[0] - $subscriptionIndex = - [Array]::IndexOf($summaryCall.Arguments, '--subscription') - Assert-Equal ` - -Actual ([string] $summaryCall.Arguments[$subscriptionIndex + 1]) ` - -Expected $script:correctionSubscriptionId ` - -Because 'the attachment preflight must stay within the approved destination' -} - Invoke-TestCase 'every Azure resource-plane call selects its subscription explicitly' { $resourceFamilies = @( @@ -1117,7 +654,6 @@ Invoke-TestCase 'every Azure resource-plane call selects its subscription explic @( 'SubscriptionGuard.ps1', 'Azure.ps1', - 'CrossSubscriptionCorrection.ps1', 'ViewerBlobAccess.ps1' | ForEach-Object { $path = Join-Path $PSScriptRoot $_ diff --git a/scripts/canvas-share-viewer-deployment/TestHarness.ps1 b/scripts/canvas-share-viewer-deployment/TestHarness.ps1 new file mode 100644 index 00000000..edeb8f27 --- /dev/null +++ b/scripts/canvas-share-viewer-deployment/TestHarness.ps1 @@ -0,0 +1,32 @@ +function Assert-Equal { + param( + [AllowNull()][object] $Actual, + [AllowNull()][object] $Expected, + [Parameter(Mandatory)][string] $Because + ) + + if ($Actual -ne $Expected) { + throw "Expected '$Expected' but got '$Actual': $Because" + } +} + +function Assert-True { + param( + [Parameter(Mandatory)][bool] $Condition, + [Parameter(Mandatory)][string] $Because + ) + + if (-not $Condition) { + throw "Expected true: $Because" + } +} + +function Invoke-TestCase { + param( + [Parameter(Mandatory)][string] $Name, + [Parameter(Mandatory)][scriptblock] $Body + ) + + & $Body + Write-Host "PASS: $Name" +} diff --git a/scripts/canvas-share-viewer-deployment/ViewerBlobAccess.Tests.ps1 b/scripts/canvas-share-viewer-deployment/ViewerBlobAccess.Tests.ps1 index a8bd6b03..0d98279f 100644 --- a/scripts/canvas-share-viewer-deployment/ViewerBlobAccess.Tests.ps1 +++ b/scripts/canvas-share-viewer-deployment/ViewerBlobAccess.Tests.ps1 @@ -3,6 +3,8 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'TestHarness.ps1') + $script:directAssignments = @() $script:inheritedAssignments = @() $script:roleDefinitions = @{} @@ -77,28 +79,6 @@ function Assert-TextContains { } } -function Assert-Equal { - param( - [Parameter(Mandatory)][object] $Actual, - [Parameter(Mandatory)][object] $Expected, - [Parameter(Mandatory)][string] $Because - ) - - if ($Actual -ne $Expected) { - throw "Expected '$Expected' but got '$Actual': $Because" - } -} - -function Invoke-TestCase { - param( - [Parameter(Mandatory)][string] $Name, - [Parameter(Mandatory)][scriptblock] $Body - ) - - & $Body - Write-Host "PASS: $Name" -} - $subscriptionId = '11111111-1111-1111-1111-111111111111' $principalObjectId = '22222222-2222-2222-2222-222222222222' $storageAccountScope = "/subscriptions/$subscriptionId/resourceGroups/storage-rg/providers/Microsoft.Storage/storageAccounts/shares" diff --git a/scripts/deploy-canvas-share-viewer.ps1 b/scripts/deploy-canvas-share-viewer.ps1 index 58568c9b..cadd5031 100644 --- a/scripts/deploy-canvas-share-viewer.ps1 +++ b/scripts/deploy-canvas-share-viewer.ps1 @@ -1,6 +1,6 @@ #requires -Version 7.4 -[CmdletBinding(DefaultParameterSetName = 'Deploy')] +[CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] @@ -26,17 +26,7 @@ param( [ValidateNotNullOrEmpty()] [string] $Registration, - [Parameter(ParameterSetName = 'Deploy')] - [switch] $ValidateOnly, - - [Parameter(Mandatory, ParameterSetName = 'PrepareMove')] - [switch] $PrepareCrossSubscriptionMove, - - [Parameter(Mandatory, ParameterSetName = 'ReconcileMove')] - [switch] $ReconcileCrossSubscriptionMove, - - [Parameter(ParameterSetName = 'ReconcileMove')] - [switch] $ConfirmPortalMoveCompleted + [switch] $ValidateOnly ) Set-StrictMode -Version Latest @@ -59,21 +49,11 @@ $deploymentSupportDirectory = Join-Path $PSScriptRoot 'canvas-share-viewer-deplo . (Join-Path $deploymentSupportDirectory 'SubscriptionGuard.ps1') . (Join-Path $deploymentSupportDirectory 'ViewerBlobAccess.ps1') . (Join-Path $deploymentSupportDirectory 'Azure.ps1') -. (Join-Path $deploymentSupportDirectory 'CrossSubscriptionCorrection.ps1') - -Assert-CrossSubscriptionCorrectionParameters ` - -ReconcileCrossSubscriptionMove:$ReconcileCrossSubscriptionMove ` - -ConfirmPortalMoveCompleted:$ConfirmPortalMoveCompleted Write-Step 'Validating local tools and repository inputs' Assert-Prerequisites $treemonConfig = Read-TreemonCanvasShareConfig -$desiredLifecycleRule = - if ($PrepareCrossSubscriptionMove) { - $null - } else { - Get-LifecycleRule -Container $treemonConfig.Container - } +$desiredLifecycleRule = Get-LifecycleRule -Container $treemonConfig.Container Write-Step 'Validating Azure subscription, tenant, and delegated publisher' $azureContext = Get-AzureContext ` @@ -87,32 +67,10 @@ $containerScope = Get-ShareContainerScope ` -StorageAccount $storageAccount ` -Container $treemonConfig.Container -if ($PrepareCrossSubscriptionMove) { - Invoke-CrossSubscriptionMovePreparation ` - -StorageAccount $storageAccount ` - -Container $treemonConfig.Container ` - -ContainerScope $containerScope ` - -SubscriptionId $azureContext.SubscriptionId - return -} - -$existingResources = - if ($ReconcileCrossSubscriptionMove) { - Get-CrossSubscriptionMoveDestinationResources ` - -SubscriptionId $azureContext.SubscriptionId - } else { - Get-ExistingResources -SubscriptionId $azureContext.SubscriptionId - } - -if ($ReconcileCrossSubscriptionMove) { - Assert-CrossSubscriptionMovedResources ` - -Existing $existingResources ` - -SubscriptionId $azureContext.SubscriptionId -} else { - Assert-ExistingResourceSafety ` - -Existing $existingResources ` - -SubscriptionId $azureContext.SubscriptionId -} +$existingResources = Get-ExistingResources -SubscriptionId $azureContext.SubscriptionId +Assert-ExistingResourceSafety ` + -Existing $existingResources ` + -SubscriptionId $azureContext.SubscriptionId if ($null -ne $existingResources.Identity) { Write-Step 'Verifying the existing viewer identity has container-only Blob access' @@ -135,20 +93,6 @@ try { return } - if ($ReconcileCrossSubscriptionMove) { - Set-CrossSubscriptionReplacementIdentity ` - -ManagedIdentity $existingResources.Identity ` - -SubscriptionId $azureContext.SubscriptionId - $existingResources = - Get-ExistingResources -SubscriptionId $azureContext.SubscriptionId - Assert-CrossSubscriptionMovedResources ` - -Existing $existingResources ` - -SubscriptionId $azureContext.SubscriptionId - Assert-ExistingResourceSafety ` - -Existing $existingResources ` - -SubscriptionId $azureContext.SubscriptionId - } - $group = Ensure-ResourceGroup ` -ExistingGroup $existingResources.Group ` -StorageAccount $storageAccount ` @@ -226,11 +170,7 @@ try { } Write-Host '' - if ($ReconcileCrossSubscriptionMove) { - Write-Host "Post-move reconciliation complete: $viewerBaseUrl" - } else { - Write-Host "Deployment complete: $viewerBaseUrl" - } + Write-Host "Deployment complete: $viewerBaseUrl" Write-Host 'The canonical App Service, identity, Entra configuration, RBAC grants, and lifecycle policy remain deployed.' } finally { Remove-Item -LiteralPath $workingDirectory -Recurse -Force -ErrorAction SilentlyContinue diff --git a/src/CanvasShareViewer/ShareLookup.fs b/src/CanvasShareViewer/ShareLookup.fs index 3a2f02e4..22c19c28 100644 --- a/src/CanvasShareViewer/ShareLookup.fs +++ b/src/CanvasShareViewer/ShareLookup.fs @@ -40,32 +40,8 @@ module internal ShareLookup = NotFound } - let resolveProperties - (reader: BlobReader) - clock - prefix - filename - cancellationToken - = - resolve - reader.ReadPropertiesExact - id - clock - prefix - filename - cancellationToken + let resolveProperties (reader: BlobReader) = + resolve reader.ReadPropertiesExact id - let resolveDocument - (reader: BlobReader) - clock - prefix - filename - cancellationToken - = - resolve - reader.ReadExact - _.Metadata - clock - prefix - filename - cancellationToken + let resolveDocument (reader: BlobReader) = + resolve reader.ReadExact _.Metadata diff --git a/src/CanvasShareViewer/ViewerApplication.fs b/src/CanvasShareViewer/ViewerApplication.fs index 9ded452d..9f4b1248 100644 --- a/src/CanvasShareViewer/ViewerApplication.fs +++ b/src/CanvasShareViewer/ViewerApplication.fs @@ -224,6 +224,12 @@ module internal ViewerApplication = context.Response.ContentLength <- 0L } + let private serveShell reader clock = + handle + (ShareLookup.resolveProperties reader clock) + ShellContentSecurityPolicy + writeShell + let private handleContent reader clock @@ -236,11 +242,7 @@ module internal ViewerApplication = writeContent context else - handle - (ShareLookup.resolveProperties reader clock) - ShellContentSecurityPolicy - writeShell - context + serveShell reader clock context let create (builder: WebApplicationBuilder) @@ -270,12 +272,7 @@ module internal ViewerApplication = app.MapGet( ShellRoute, - RequestDelegate( - handle - (ShareLookup.resolveProperties reader clock) - ShellContentSecurityPolicy - writeShell - ) + RequestDelegate(serveShell reader clock) ) |> ignore diff --git a/src/Tests/CanvasShareTests.fs b/src/Tests/CanvasShareTests.fs index 35f26147..71cd2ce1 100644 --- a/src/Tests/CanvasShareTests.fs +++ b/src/Tests/CanvasShareTests.fs @@ -174,21 +174,6 @@ type ViewerUrlTests() = let prefix = "0123456789AbCdEfGhIjKl" - [] - [] - [] - member _.``publisher naming satisfies the viewer wire contract``(filename: string) = - Assert.Multiple(fun () -> - Assert.That( - PrefixLength, - Is.EqualTo(CanvasShareViewer.SharePath.PrefixLength)) - Assert.That( - CanvasShareViewer.SharePath.tryCreate - (generatePrefix ()) - filename - |> Option.isSome, - Is.True)) - [] member _.``viewer URL uses the canonical deployed origin and clean c path``() = let url = diff --git a/src/Tests/CanvasShareViewerContainmentTests.fs b/src/Tests/CanvasShareViewerContainmentTests.fs index 05532d83..7643f20e 100644 --- a/src/Tests/CanvasShareViewerContainmentTests.fs +++ b/src/Tests/CanvasShareViewerContainmentTests.fs @@ -3,88 +3,12 @@ module Tests.CanvasShareViewerContainmentTests open System open System.Collections.Concurrent open System.IO -open System.Net -open System.Net.Http open System.Text.Json -open System.Xml.Linq open Microsoft.Playwright open Microsoft.Playwright.NUnit open NUnit.Framework open Tests.CanvasShareViewerContainmentTestHelpers -let private shellContentSecurityPolicy = - "default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'" - -let private contentContentSecurityPolicy = - "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; sandbox allow-scripts" - -let private getIframeContent - (client: HttpClient) - (url: string) - = - task { - use request = - new HttpRequestMessage(HttpMethod.Get, url) - - [ - "Sec-Fetch-Site", "same-origin" - "Sec-Fetch-Mode", "navigate" - "Sec-Fetch-Dest", "iframe" - ] - |> List.iter (fun (name: string, value: string) -> - request.Headers.TryAddWithoutValidation( - name, - value - ) - |> ignore) - - return! client.SendAsync(request) - } - -let private singleHeader - name - (response: HttpResponseMessage) - = - response.Headers.GetValues(name) - |> Seq.exactlyOne - -let private assertPolicy - expectedContentSecurityPolicy - (response: HttpResponseMessage) - = - Assert.Multiple(fun () -> - Assert.That( - singleHeader - "Content-Security-Policy" - response, - Is.EqualTo(expectedContentSecurityPolicy) - ) - - Assert.That( - singleHeader - "X-Content-Type-Options" - response, - Is.EqualTo("nosniff") - ) - - Assert.That( - singleHeader "Referrer-Policy" response, - Is.EqualTo("no-referrer") - ) - - Assert.That( - singleHeader "Cache-Control" response, - Is.EqualTo("no-store") - )) - -let private parseShell (html: string) = - html.Replace( - "", - "", - StringComparison.OrdinalIgnoreCase - ) - |> XDocument.Parse - let private artifactDirectory () = Environment.GetEnvironmentVariable( "CANVAS_VIEWER_VERIFICATION_ARTIFACT_DIR" @@ -385,135 +309,6 @@ let private writeBrowserEvidence type CanvasShareViewerContainmentTests() = inherit PageTest() - [] - member _.``live viewer routes enforce the exact wire contract``() = - withContainmentHarness (fun harness -> - task { - use client = new HttpClient() - - use! hostileShell = - client.GetAsync( - $"{harness.ViewerBaseUrl}/c/{validPrefix}/hostile.html" - ) - - use! hostileContent = - getIframeContent - client - $"{harness.ViewerBaseUrl}/c/{validPrefix}/hostile.html/content" - - use! benignShell = - client.GetAsync( - $"{harness.ViewerBaseUrl}/c/{validPrefix}/self-contained.html" - ) - - use! benignContent = - getIframeContent - client - $"{harness.ViewerBaseUrl}/c/{validPrefix}/self-contained.html/content" - - let! shellHtml = - hostileShell.Content.ReadAsStringAsync() - - let shellDom = parseShell shellHtml - - let iframe = - shellDom.Descendants( - XName.Get("iframe") - ) - |> Seq.exactlyOne - - let sandbox = - iframe - .Attribute(XName.Get("sandbox")) - .Value - - [ - hostileShell - hostileContent - benignShell - benignContent - ] - |> List.iter (fun response -> - Assert.That( - response.StatusCode, - Is.EqualTo(HttpStatusCode.OK) - )) - - assertPolicy - shellContentSecurityPolicy - hostileShell - assertPolicy - contentContentSecurityPolicy - hostileContent - assertPolicy - shellContentSecurityPolicy - benignShell - assertPolicy - contentContentSecurityPolicy - benignContent - - Assert.Multiple(fun () -> - Assert.That( - sandbox, - Is.EqualTo("allow-scripts"), - "the iframe sandbox token set must be exact" - ) - - Assert.That( - harness.ViewerPort, - Is.Not.EqualTo(5000) - ) - - Assert.That( - harness.ProbePort, - Is.Not.EqualTo(5000) - )) - - let evidence = - JsonSerializer.Serialize( - {| viewerBaseUrl = - harness.ViewerBaseUrl - viewerPort = harness.ViewerPort - probePort = harness.ProbePort - statuses = - [| - int hostileShell.StatusCode - int hostileContent.StatusCode - int benignShell.StatusCode - int benignContent.StatusCode - |] - iframeSandbox = sandbox - shellCsp = - singleHeader - "Content-Security-Policy" - hostileShell - contentCsp = - singleHeader - "Content-Security-Policy" - hostileContent - xContentTypeOptions = - singleHeader - "X-Content-Type-Options" - hostileContent - referrerPolicy = - singleHeader - "Referrer-Policy" - hostileContent - cacheControl = - singleHeader - "Cache-Control" - hostileContent - blobRequests = - harness.BlobRequests.ToArray() |}, - JsonSerializerOptions( - WriteIndented = true - ) - ) - - TestContext.Out.WriteLine(evidence) - writeArtifact "wire-contract.json" evidence - }) - [] member this.``hostile fixture cannot escape the shell iframe``() = withContainmentHarness (fun harness -> diff --git a/src/Tests/CanvasShareViewerTests.fs b/src/Tests/CanvasShareViewerTests.fs index 6ab4e5ee..337b6c31 100644 --- a/src/Tests/CanvasShareViewerTests.fs +++ b/src/Tests/CanvasShareViewerTests.fs @@ -415,6 +415,11 @@ type SharePathValidationTests() = [] member _.``prefix must be exactly 22 characters``() = + Assert.That( + SharePath.PrefixLength, + Is.EqualTo(Server.CanvasShare.PrefixLength) + ) + Assert.That( SharePath.tryCreate "0123456789ABCDEFGHIJK" From 10974f093cdfe558718676f06b82d7c6099ed6bc Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Wed, 26 Aug 2026 16:17:14 +0200 Subject: [PATCH 24/27] Improve canvas viewer consent experience Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/canvas-share-viewer-deployment.md | 38 +- docs/spec/canvas-sharing.md | 29 +- .../canvas-share-viewer-deployment/Azure.ps1 | 234 +++++++++-- .../canvas-share-viewer-deployment/Common.ps1 | 49 +++ .../Deployment.Tests.ps1 | 397 +++++++++++++++++- .../treemon-canvas-viewer-logo.png | Bin 0 -> 79956 bytes scripts/deploy-canvas-share-viewer.ps1 | 4 - 7 files changed, 692 insertions(+), 59 deletions(-) create mode 100644 scripts/canvas-share-viewer-deployment/treemon-canvas-viewer-logo.png diff --git a/docs/canvas-share-viewer-deployment.md b/docs/canvas-share-viewer-deployment.md index 0488c0df..f981547d 100644 --- a/docs/canvas-share-viewer-deployment.md +++ b/docs/canvas-share-viewer-deployment.md @@ -24,7 +24,9 @@ domain. - Azure permissions to create resources, update the storage account, assign roles at the Blob container scope, and disable App Service basic publishing credentials. Entra permissions must - allow creation or ownership of the dedicated app registration and its federated credential. + allow creation or ownership of the dedicated app registration and its federated credential, + updating the matching Enterprise Application, and uploading the registration logo through + Microsoft Graph. The operator must also be able to list the viewer identity's role assignments throughout the subscription and inherited parent scopes and read their role definitions. - If the tenant enforces `serviceManagementReference` on app-registration changes, the signed-in @@ -65,7 +67,6 @@ Run the read-only validation first: -ResourceGroup '' ` -Plan '' ` -Identity '' ` - -Registration '' ` -ValidateOnly ``` @@ -88,8 +89,7 @@ Remove `-ValidateOnly` to apply the same plan: -Tenant '' ` -ResourceGroup '' ` -Plan '' ` - -Identity '' ` - -Registration '' + -Identity '' ``` The script is idempotent and is intended to be run a second time with the same values. It: @@ -101,18 +101,25 @@ The script is idempotent and is intended to be run a second time with the same v `Storage Blob Data Contributor`, both at that container's exact ARM scope. Before mutating an existing deployment and again during final verification, it rejects broader viewer Blob-read assignments discovered anywhere in the subscription or inherited from a parent scope. -3. Creates or reuses one uniquely named, secret-free, current-tenant `AzureADMyOrg` app - registration and service principal. The registration accepts only the canonical App Service - callback. On a restricted tenant's specific `serviceManagementReference` error, creation or - update retries with the one unambiguous reference already carried by applications the delegated - publisher owns. It enables ID-token issuance for Easy Auth's `code id_token` form-post callback - while leaving browser access-token issuance disabled. +3. Creates or reuses the secret-free, current-tenant `AzureADMyOrg` app registration and service + principal named **Treemon Canvas Viewer**. A bounded lookup recognizes the former + `treemon-canvas-viewer-auth` name so the existing registration is renamed rather than duplicated. + The registration uses the canonical viewer URL as its homepage, carries a plain-language + read-only description, and uploads a tracked 215x215 PNG derived from the Treemon PWA icon. It + accepts only the canonical App Service callback and declares no Graph or other API permissions. On a restricted + tenant's specific `serviceManagementReference` error, creation or update retries with the one + unambiguous reference already carried by applications the delegated publisher owns. It enables + ID-token issuance for Easy Auth's `code id_token` form-post callback while leaving browser + access-token issuance disabled. 4. Adds a federated credential whose subject is the managed identity principal. Easy Auth uses the slot-sticky `OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID` setting and its `clientSecretSettingName` sentinel, so no client secret is created. -5. Requires Easy Auth before requests reach the viewer, uses the tenant's v2 issuer, requests no - extra login scopes, requires HTTPS, disables the token store, and pins both the .NET host and - ASP.NET Core environments to `Production`. +5. Requires Easy Auth before requests reach the viewer, uses the tenant's v2 issuer, explicitly + requests only the `openid` scope, requires HTTPS, disables the token store, and pins both the + .NET host and ASP.NET Core environments to `Production`. This removes App Service's unused + default `profile` and `email` requests. Microsoft Entra can still display its generic + "Maintain access to data you have given it access to" consent line even though the request omits + `offline_access` and the disabled token store cannot retain provider refresh tokens. 6. Merges `expire-shared-canvas-docs` into the storage account's complete lifecycle policy while preserving unrelated rules. Deletion starts only after more than 31 days, beyond the 30-day maximum share lifetime. @@ -137,8 +144,9 @@ The script is idempotent and is intended to be run a second time with the same v running server, so a settings change made in the UI at the same moment could be overwritten. The automation does not invoke Treemon server lifecycle commands and does not bind any local -Treemon port. Temporary build and JSON files are deleted on exit. It does not request or print -access tokens, create deployment credentials, or read a publishing profile. +Treemon port. Temporary build and JSON files are deleted on exit. It requests one Microsoft Graph +token in memory to upload the app logo but never prints or persists it. It creates no deployment +credentials and does not read a publishing profile. ## After deployment diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index 97362df9..5f73aaab 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -164,7 +164,10 @@ Easy Auth via a managed-identity federated credential rather than a long-lived client secret. The registration enables ID-token issuance because Easy Auth's browser callback requests `response_type=code id_token` with `response_mode=form_post`; browser access-token issuance - remains disabled. + remains disabled. Easy Auth explicitly requests only `openid`, the minimum scope needed for the + ID token and stable subject identifier; the viewer requests neither profile nor email claims. + The registration is presented as **Treemon Canvas Viewer**, with the canonical viewer homepage, + a plain-language read-only description, and a 215x215 logo derived from the Treemon PWA icon. - Two routes divide responsibility: a shell route (`/c//`) validates the request and expiry through an exact Blob properties lookup and renders a minimal HTML page without downloading the document body; a content route @@ -210,9 +213,11 @@ - Expiry is enforced synchronously on every request against the metadata the publisher wrote. The account's Blob lifecycle policy still runs, but only as eventual cleanup after the access deadline, not as the authorization boundary. -- Easy Auth's token store is disabled and no downstream Graph scopes are requested; the viewer - origin exposes no upload/delete/admin API -- it is read-only, with only the shell and content - routes. +- Easy Auth's token store is disabled and no downstream Graph or other API permissions are + declared. Microsoft Entra may still show its generic "Maintain access" consent text because + `offline_access` is implicit in delegated consent, but the authorization request omits that scope + and the viewer cannot retain provider refresh tokens. The viewer origin exposes no + upload/delete/admin API -- it is read-only, with only the shell and content routes. ### Wire contract (publisher <-> viewer) @@ -244,8 +249,9 @@ remote-URL fetch that would otherwise be a working exfiltration channel. ### Provisioning - `scripts/deploy-canvas-share-viewer.ps1` is the idempotent validate/apply entry point. Deployment - names are operator inputs; storage and publisher identity come from machine configuration and the - selected Azure CLI user. Exact subscription, tenant, and resource identifiers remain private. + names except the canonical app registration are operator inputs; storage and publisher identity + come from machine configuration and the selected Azure CLI user. Exact subscription, tenant, and + resource identifiers remain private. - The requested, approved, and selected subscriptions must resolve to the same enabled subscription before any resource-provider or Entra operation. Every resource-plane call names that subscription explicitly, and failures identify the protected setting without revealing its value. @@ -255,8 +261,9 @@ remote-URL fetch that would otherwise be a working exfiltration channel. identity whose effective Blob-read access is audited before mutation and after deployment; any read grant broader than the configured private container fails closed and is never auto-deleted. - Apply reconciles the private container, lifecycle rule, B1 Linux plan, App Service, secret-free - Easy Auth federation, production environment settings, and canonical `viewerBaseUrl`. Validation - performs the same safety checks plus a local Release publish without changing Azure or config. + Easy Auth federation, openid-only login scope, user-facing registration branding, production + environment settings, and canonical `viewerBaseUrl`. Validation performs the same safety checks + plus a local Release publish without changing Azure or config. - The lifecycle policy is merged with unrelated account rules, and verification removes only its temporary document fixtures. Canonical resources and access grants remain deployed. - Feature development, deployment, and verification never run a production lifecycle command @@ -298,6 +305,8 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | Enforce expiry in the viewer at request time rather than relying on Blob lifecycle deletion | Lifecycle deletion runs on a daily-ish schedule and is a backstop; relying on it alone would leave documents readable past their promised expiry. | | Prefer a managed-identity federated credential over an Easy Auth client secret | Avoids minting, storing, or rotating a long-lived secret for the viewer's app registration. | | Enable registration ID-token issuance but not browser access-token issuance | App Service Easy Auth uses an OIDC hybrid `code id_token` form-post callback and rejects sign-in when the registration cannot issue that ID token; it redeems the code server-side through managed-identity federation, so browser access-token issuance remains unnecessary. | +| Request only the `openid` login scope | The viewer needs an ID token to authenticate a tenant subject but reads no profile/email claims and calls no downstream API. Explicit `scope=openid` prevents App Service's broader `openid profile email` defaults from adding an unused basic-profile consent ask. | +| Give the registration a canonical product identity | A fixed **Treemon Canvas Viewer** name, read-only description, canonical homepage, and PWA logo make the consent/App info surfaces recognizable. The former technical name is accepted only as a bounded migration lookup so deployment renames the durable registration instead of creating a duplicate. | | Store expiry as blob metadata rather than a separate data store | Keeps the expiry attached to the artifact it governs, with no second store to keep in sync; it travels and disappears with the blob. | | Re-check segments and expiry on the content route instead of trusting the shell | The recipient holds the URL, so the content route is directly reachable; a shell-only check would leave an expired share readable by editing the path. | | Use a properties-only Blob lookup for the shell and reserve the body read for the content route | The shell needs only existence and expiry metadata, so downloading and discarding the complete document there would double the transferred document bytes without strengthening validation. | @@ -338,6 +347,7 @@ remote-URL fetch that would otherwise be a working exfiltration channel. | `scripts/canvas-share-viewer-deployment/SubscriptionGuard.ps1` | Fail-closed approved/requested/selected subscription, tenant, and delegated-publisher validation with private lookup diagnostics redacted | | `scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1` | Windows/Azure CLI shape, packaging-output, restricted-tenant registration, and Easy Auth callback regressions | | `scripts/canvas-share-viewer-deployment/ViewerBlobAccess.ps1` | Fail-closed audit of the viewer identity's effective Blob-read data-plane assignments | +| `scripts/canvas-share-viewer-deployment/treemon-canvas-viewer-logo.png` | Entra-compliant logo derived from the Treemon PWA icon | | `scripts/canvas-share-lifecycle-policy.json` | Container-filtered deletion rule starting after 31 days | | `docs/canvas-share-viewer-deployment.md` | Local operator prerequisites, dry run, apply, and durable-resource guidance | @@ -350,6 +360,9 @@ remote-URL fetch that would otherwise be a working exfiltration channel. browser navigation headers (`Accept: text/html`), since App Service Easy Auth may return an empty 401 instead of a redirect to an API-style client. Any identity the tenant authenticates -- member or B2B guest -- views the document; an identity outside the tenant is denied. +- The authorization redirect requests exactly `scope=openid` with `response_type=code id_token`; + the app registration declares no Graph or other API permissions, and both the registration and + Enterprise Application show the canonical name, read-only description, homepage, and PWA logo. - The control-plane RBAC audit finds no effective Blob data-plane read assignment outside the share container, and the live second-container probe under the deployed viewer identity still returns 403 as defense in depth. diff --git a/scripts/canvas-share-viewer-deployment/Azure.ps1 b/scripts/canvas-share-viewer-deployment/Azure.ps1 index f166ed7c..6abbbc08 100644 --- a/scripts/canvas-share-viewer-deployment/Azure.ps1 +++ b/scripts/canvas-share-viewer-deployment/Azure.ps1 @@ -1,13 +1,31 @@ +$registrationDisplayName = 'Treemon Canvas Viewer' +$legacyRegistrationDisplayName = 'treemon-canvas-viewer-auth' +$registrationDescription = + 'Read-only viewer for Treemon canvas documents shared with authenticated members and guests of this Microsoft Entra tenant. It does not access Microsoft Graph, profile data, or offline tokens.' +$registrationLogoPath = + Join-Path $PSScriptRoot 'treemon-canvas-viewer-logo.png' +$easyAuthLoginParameter = 'scope=openid' + function Get-ExactAppRegistration { $registrations = @( - Invoke-AzJson -Arguments @( - 'ad', 'app', 'list', - '--display-name', $Registration) - | Where-Object displayName -CEQ $Registration + @( + $registrationDisplayName + $legacyRegistrationDisplayName + ) + | Sort-Object -Unique + | ForEach-Object { + $displayName = $_ + Invoke-AzJson -Arguments @( + 'ad', 'app', 'list', + '--display-name', $displayName) + | Where-Object displayName -CEQ $displayName + } + | Group-Object appId + | ForEach-Object { $_.Group[0] } ) if ($registrations.Count -gt 1) { - throw "More than one Entra app registration is named '$Registration'. Use a unique dedicated registration name." + throw "More than one Treemon canvas viewer app registration exists. Remove the duplicate before deployment." } if ($registrations.Count -eq 1) { @@ -21,14 +39,47 @@ function Assert-RegistrationIsDedicated { param([Parameter(Mandatory)][pscustomobject] $AppRegistration) if (@($AppRegistration.passwordCredentials).Count -gt 0) { - throw "Entra app registration '$Registration' has a client secret. Use a dedicated secret-free registration." + throw "Entra app registration '$registrationDisplayName' has a client secret. Use a dedicated secret-free registration." + } + + if (@($AppRegistration.requiredResourceAccess).Count -gt 0) { + throw "Entra app registration '$registrationDisplayName' declares API permissions. The canvas viewer requires only OpenID sign-in." } $redirectUris = @($AppRegistration.web.redirectUris) $unexpectedRedirectUris = @($redirectUris | Where-Object { $_ -cne $callbackUrl }) if ($unexpectedRedirectUris.Count -gt 0) { - throw "Entra app registration '$Registration' has redirect URIs other than the canonical App Service callback. Use a dedicated registration." + throw "Entra app registration '$registrationDisplayName' has redirect URIs other than the canonical App Service callback. Use a dedicated registration." + } +} + +function Assert-AppRegistrationBranding { + param([Parameter(Mandatory)][pscustomobject] $AppRegistration) + + $info = Get-AzureResourcePropertyValue -Resource $AppRegistration -Name 'info' + $logoUrl = + if ($null -eq $info) { + '' + } else { + [string] (Get-AzureResourcePropertyValue -Resource $info -Name 'logoUrl') + } + + if ([string] $AppRegistration.displayName -cne $registrationDisplayName -or + [string] $AppRegistration.description -cne $registrationDescription -or + [string] $AppRegistration.web.homePageUrl -cne $viewerBaseUrl -or + [string]::IsNullOrWhiteSpace($logoUrl)) { + throw "Entra app registration '$registrationDisplayName' is missing its canonical name, description, homepage, or logo." + } +} + +function Assert-ServicePrincipalBranding { + param([Parameter(Mandatory)][pscustomobject] $ServicePrincipal) + + if ([string] $ServicePrincipal.displayName -cne $registrationDisplayName -or + [string] $ServicePrincipal.description -cne $registrationDescription -or + [string] $ServicePrincipal.homepage -cne $viewerBaseUrl) { + throw "Enterprise application '$registrationDisplayName' is missing its canonical name, description, or homepage." } } @@ -92,7 +143,7 @@ function Assert-AppRegistrationAuthenticationFlow { [string] $redirectUris[0] -cne $callbackUrl -or -not $idTokenIssuanceEnabled -or $accessTokenIssuanceEnabled) { - throw "Entra app registration '$Registration' is not configured for Easy Auth's single-tenant code/id_token callback." + throw "Entra app registration '$registrationDisplayName' is not configured for Easy Auth's single-tenant code/id_token callback." } } @@ -523,8 +574,9 @@ function New-ViewerAppRegistration { Invoke-AzJson -Arguments (@( 'ad', 'app', 'create', - '--display-name', $Registration, + '--display-name', $registrationDisplayName, '--sign-in-audience', 'AzureADMyOrg', + '--web-home-page-url', $viewerBaseUrl, '--web-redirect-uris', $callbackUrl ) + $referenceArguments) } @@ -545,20 +597,108 @@ function Update-ViewerAppRegistration { Invoke-AzNone -Arguments (@( 'ad', 'app', 'update', '--id', $AppId, + '--display-name', $registrationDisplayName, '--sign-in-audience', 'AzureADMyOrg', + '--web-home-page-url', $viewerBaseUrl, '--web-redirect-uris', $callbackUrl, '--enable-access-token-issuance', 'false', - '--enable-id-token-issuance', 'true' + '--enable-id-token-issuance', 'true', + '--set', "description=$registrationDescription" ) + $referenceArguments) } +function Set-ViewerAppRegistrationLogo { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ObjectId + ) + + Write-Step "Setting Entra app registration '$registrationDisplayName' logo" + $graphToken = + Invoke-AzJson -Arguments @( + 'account', 'get-access-token', + '--resource-type', 'ms-graph') + $accessToken = [string] $graphToken.accessToken + + if ([string]::IsNullOrWhiteSpace($accessToken)) { + throw 'Azure CLI did not return a Microsoft Graph access token for the logo upload.' + } + + $secureAccessToken = + ConvertTo-SecureString ` + -String $accessToken ` + -AsPlainText ` + -Force + + try { + Invoke-RestMethod ` + -Uri "https://graph.microsoft.com/v1.0/applications/$ObjectId/logo" ` + -Method Put ` + -Authentication Bearer ` + -Token $secureAccessToken ` + -ContentType 'image/png' ` + -InFile $registrationLogoPath | + Out-Null + } catch { + throw "Could not set Entra app registration '$registrationDisplayName' logo: $($_.Exception.Message)" + } +} + +function Set-ViewerServicePrincipalBranding { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AppId + ) + + Invoke-AzNone -Arguments @( + 'ad', 'sp', 'update', + '--id', $AppId, + '--set', + "displayName=$registrationDisplayName", + "description=$registrationDescription", + "homepage=$viewerBaseUrl") +} + +function Get-BrandedAppRegistration { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AppId + ) + + $attempts = 5 + + for ($attempt = 1; $attempt -le $attempts; $attempt++) { + $appRegistration = + Invoke-AzJson -Arguments @( + 'ad', 'app', 'show', + '--id', $AppId) + $info = Get-AzureResourcePropertyValue -Resource $appRegistration -Name 'info' + $logoUrl = + if ($null -eq $info) { + '' + } else { + [string] (Get-AzureResourcePropertyValue -Resource $info -Name 'logoUrl') + } + + if (-not [string]::IsNullOrWhiteSpace($logoUrl) -or + $attempt -eq $attempts) { + return $appRegistration + } + + Start-Sleep -Seconds 2 + } +} + function Ensure-AppRegistration { param([AllowNull()][pscustomobject] $ExistingRegistration) $serviceManagementReference = '' $appRegistration = if ($null -eq $ExistingRegistration) { - Write-Step "Creating single-tenant Entra app registration '$Registration'" + Write-Step "Creating single-tenant Entra app registration '$registrationDisplayName'" try { New-ViewerAppRegistration -ServiceManagementReference '' @@ -600,6 +740,8 @@ function Ensure-AppRegistration { -ServiceManagementReference $serviceManagementReference } + Set-ViewerAppRegistrationLogo -ObjectId ([string] $appRegistration.id) + $servicePrincipals = @( Invoke-AzJson -Arguments @( 'ad', 'sp', 'list', @@ -611,9 +753,11 @@ function Ensure-AppRegistration { 'ad', 'sp', 'create', '--id', [string] $appRegistration.appId) } elseif ($servicePrincipals.Count -gt 1) { - throw "More than one service principal exists for Entra app registration '$Registration'." + throw "More than one service principal exists for Entra app registration '$registrationDisplayName'." } + Set-ViewerServicePrincipalBranding -AppId ([string] $appRegistration.appId) + Invoke-AzJson -Arguments @('ad', 'app', 'show', '--id', [string] $appRegistration.appId) } @@ -887,7 +1031,7 @@ function Ensure-EasyAuth { openIdIssuer = "https://login.microsoftonline.com/$TenantId/v2.0" } login = [ordered]@{ - loginParameters = @() + loginParameters = @($easyAuthLoginParameter) } } } @@ -909,6 +1053,30 @@ function Ensure-EasyAuth { '--subscription', $SubscriptionId) } +function Assert-EasyAuthConfiguration { + param( + [Parameter(Mandatory)][pscustomobject] $AppRegistration, + [Parameter(Mandatory)][string] $TenantId, + [Parameter(Mandatory)][pscustomobject] $AuthSettings + ) + + $azureAd = $AuthSettings.properties.identityProviders.azureActiveDirectory + $loginParameters = @($azureAd.login.loginParameters) + + if (-not [bool] $AuthSettings.properties.platform.enabled -or + -not [bool] $AuthSettings.properties.globalValidation.requireAuthentication -or + [string] $AuthSettings.properties.globalValidation.unauthenticatedClientAction -cne 'RedirectToLoginPage' -or + -not [bool] $azureAd.enabled -or + [string] $azureAd.registration.clientId -cne [string] $AppRegistration.appId -or + [string] $azureAd.registration.clientSecretSettingName -cne $managedIdentityAssertionSetting -or + [string] $azureAd.registration.openIdIssuer -cne "https://login.microsoftonline.com/$TenantId/v2.0" -or + $loginParameters.Count -ne 1 -or + [string] $loginParameters[0] -cne $easyAuthLoginParameter -or + [bool] $AuthSettings.properties.login.tokenStore.enabled) { + throw 'Easy Auth is not configured for openid-only, secret-free, single-tenant authentication with the token store disabled.' + } +} + function Disable-BasicPublishingCredentials { param([Parameter(Mandatory)][string] $SubscriptionId) @@ -1040,30 +1208,38 @@ function Assert-DeployedState { throw 'An Easy Auth client-secret setting is present.' } - $currentAppRegistration = Invoke-AzJson -Arguments @( - 'ad', 'app', 'show', - '--id', [string] $AppRegistration.appId) + $currentAppRegistration = + Get-BrandedAppRegistration ` + -AppId ([string] $AppRegistration.appId) Assert-AppRegistrationAuthenticationFlow ` -AppRegistration $currentAppRegistration + Assert-AppRegistrationBranding ` + -AppRegistration $currentAppRegistration + $currentServicePrincipals = @( + Invoke-AzJson -Arguments @( + 'ad', 'sp', 'list', + '--filter', "appId eq '$($AppRegistration.appId)'") + ) + + if ($currentServicePrincipals.Count -ne 1) { + throw "Expected exactly one enterprise application for '$registrationDisplayName'." + } + + $currentServicePrincipal = Invoke-AzJson -Arguments @( + 'ad', 'sp', 'show', + '--id', [string] $AppRegistration.appId) + Assert-ServicePrincipalBranding ` + -ServicePrincipal $currentServicePrincipal $authSettings = Invoke-AzJson -Arguments @( 'rest', '--method', 'get', '--uri', "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Web/sites/$appName/config/authsettingsV2?api-version=2023-12-01", '--subscription', $SubscriptionId) - $azureAd = $authSettings.properties.identityProviders.azureActiveDirectory - - if (-not [bool] $authSettings.properties.platform.enabled -or - -not [bool] $authSettings.properties.globalValidation.requireAuthentication -or - [string] $authSettings.properties.globalValidation.unauthenticatedClientAction -cne 'RedirectToLoginPage' -or - -not [bool] $azureAd.enabled -or - [string] $azureAd.registration.clientId -cne [string] $AppRegistration.appId -or - [string] $azureAd.registration.clientSecretSettingName -cne $managedIdentityAssertionSetting -or - [string] $azureAd.registration.openIdIssuer -cne "https://login.microsoftonline.com/$TenantId/v2.0" -or - @($azureAd.login.loginParameters).Count -ne 0 -or - [bool] $authSettings.properties.login.tokenStore.enabled) { - throw 'Easy Auth is not configured for required, secret-free, single-tenant authentication with the token store disabled.' - } + Assert-EasyAuthConfiguration ` + -AppRegistration $AppRegistration ` + -TenantId $TenantId ` + -AuthSettings $authSettings foreach ($policyName in @('ftp', 'scm')) { $policy = Invoke-AzJson -Arguments @( diff --git a/scripts/canvas-share-viewer-deployment/Common.ps1 b/scripts/canvas-share-viewer-deployment/Common.ps1 index efd55456..d3baa913 100644 --- a/scripts/canvas-share-viewer-deployment/Common.ps1 +++ b/scripts/canvas-share-viewer-deployment/Common.ps1 @@ -217,6 +217,53 @@ function Set-TreemonViewerBaseUrl { } } +function Assert-RegistrationLogo { + param([Parameter(Mandatory)][string] $Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Viewer registration logo was not found at '$Path'." + } + + $bytes = [IO.File]::ReadAllBytes($Path) + + if ($bytes.Length -gt 100KB) { + throw "Viewer registration logo at '$Path' exceeds 100 KB." + } + + $hasPngHeader = + $bytes.Length -ge 24 -and + $bytes[0] -eq 0x89 -and + $bytes[1] -eq 0x50 -and + $bytes[2] -eq 0x4E -and + $bytes[3] -eq 0x47 -and + $bytes[4] -eq 0x0D -and + $bytes[5] -eq 0x0A -and + $bytes[6] -eq 0x1A -and + $bytes[7] -eq 0x0A + $width = + if ($hasPngHeader) { + ($bytes[16] -shl 24) -bor + ($bytes[17] -shl 16) -bor + ($bytes[18] -shl 8) -bor + $bytes[19] + } else { + 0 + } + $height = + if ($hasPngHeader) { + ($bytes[20] -shl 24) -bor + ($bytes[21] -shl 16) -bor + ($bytes[22] -shl 8) -bor + $bytes[23] + } else { + 0 + } + + if (-not $hasPngHeader -or $width -ne 215 -or $height -ne 215) { + throw "Viewer registration logo at '$Path' must be a 215x215 PNG." + } +} + function Assert-Prerequisites { if (-not (Get-Command az -ErrorAction SilentlyContinue)) { throw 'Azure CLI (az) is required.' @@ -234,6 +281,8 @@ function Assert-Prerequisites { throw "Lifecycle policy was not found at '$lifecyclePolicyPath'." } + Assert-RegistrationLogo -Path $registrationLogoPath + $azVersions = Invoke-AzJson -Arguments @('version') $azVersion = [version] $azVersions.'azure-cli' diff --git a/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 b/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 index e41880cc..27120219 100644 --- a/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 +++ b/scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 @@ -83,13 +83,13 @@ $contributorRole = 'Storage Blob Data Contributor' $ResourceGroup = 'viewer-rg' $Plan = 'viewer-plan' $Identity = 'viewer-identity' -$Registration = 'viewer-registration' . (Join-Path $PSScriptRoot 'Azure.ps1') $script:scenario = '' $script:azNoneCalls = @() $script:azJsonCalls = @() +$script:restMethodCalls = @() $script:subscriptionAccounts = @{} $script:subscriptionLookupFailures = @{} $script:selectedAccount = $null @@ -107,12 +107,39 @@ $script:webAppResult = } $script:registrationResult = [pscustomobject]@{ + id = '88888888-7777-6666-5555-444444444444' appId = '99999999-8888-7777-6666-555555555555' - displayName = $Registration + displayName = $registrationDisplayName + description = $registrationDescription signInAudience = 'AzureADMyOrg' passwordCredentials = @() + requiredResourceAccess = @() serviceManagementReference = $script:serviceManagementReference + info = [pscustomobject]@{ + logoUrl = 'https://aadcdn.msftauthimages.net/logo.png' + } + web = [pscustomobject]@{ + homePageUrl = $viewerBaseUrl + redirectUris = @($callbackUrl) + implicitGrantSettings = [pscustomobject]@{ + enableAccessTokenIssuance = $false + enableIdTokenIssuance = $true + } + } + } +$script:legacyRegistrationResult = + [pscustomobject]@{ + id = $script:registrationResult.id + appId = $script:registrationResult.appId + displayName = $legacyRegistrationDisplayName + description = $null + signInAudience = 'AzureADMyOrg' + passwordCredentials = @() + requiredResourceAccess = @() + serviceManagementReference = $script:serviceManagementReference + info = [pscustomobject]@{ logoUrl = $null } web = [pscustomobject]@{ + homePageUrl = $null redirectUris = @($callbackUrl) implicitGrantSettings = [pscustomobject]@{ enableAccessTokenIssuance = $false @@ -120,11 +147,87 @@ $script:registrationResult = } } } +$script:servicePrincipalResult = + [pscustomobject]@{ + appId = $script:registrationResult.appId + displayName = $registrationDisplayName + description = $registrationDescription + homepage = $viewerBaseUrl + } function Write-Step { param([Parameter(Mandatory)][string] $Message) } +Invoke-TestCase 'registration logo is a bounded 215-pixel PNG' { + Assert-RegistrationLogo -Path $registrationLogoPath + + $temporaryDirectory = + Join-Path ([IO.Path]::GetTempPath()) "treemon-logo-test-$([Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null + + try { + $missingRejected = $false + + try { + Assert-RegistrationLogo ` + -Path (Join-Path $temporaryDirectory 'missing.png') + } catch { + $missingRejected = + $_.Exception.Message -match 'was not found' + } + + Assert-True ` + -Condition $missingRejected ` + -Because 'a missing consent-screen logo must fail before Azure mutation' + + $wrongSizePath = Join-Path $temporaryDirectory 'wrong-size.png' + $wrongSizeBytes = [byte[]]::new(24) + $wrongSizeBytes[0] = 0x89 + $wrongSizeBytes[1] = 0x50 + $wrongSizeBytes[2] = 0x4E + $wrongSizeBytes[3] = 0x47 + $wrongSizeBytes[4] = 0x0D + $wrongSizeBytes[5] = 0x0A + $wrongSizeBytes[6] = 0x1A + $wrongSizeBytes[7] = 0x0A + $wrongSizeBytes[19] = 0xC0 + $wrongSizeBytes[23] = 0xC0 + [IO.File]::WriteAllBytes($wrongSizePath, $wrongSizeBytes) + $wrongSizeRejected = $false + + try { + Assert-RegistrationLogo -Path $wrongSizePath + } catch { + $wrongSizeRejected = + $_.Exception.Message -match '215x215 PNG' + } + + Assert-True ` + -Condition $wrongSizeRejected ` + -Because 'a PWA icon must be derived to the exact Entra dimensions' + + $oversizedPath = Join-Path $temporaryDirectory 'oversized.png' + [IO.File]::WriteAllBytes( + $oversizedPath, + [byte[]]::new(100KB + 1)) + $oversizedRejected = $false + + try { + Assert-RegistrationLogo -Path $oversizedPath + } catch { + $oversizedRejected = + $_.Exception.Message -match 'exceeds 100 KB' + } + + Assert-True ` + -Condition $oversizedRejected ` + -Because 'an oversized logo must fail before Azure mutation' + } finally { + Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue + } +} + function Invoke-AzNone { param([Parameter(Mandatory)][string[]] $Arguments) @@ -195,6 +298,11 @@ function Invoke-AzJson { } 'registration' { switch ($command) { + 'account get-access-token --resource-type' { + return [pscustomobject]@{ + accessToken = 'fixture-graph-token' + } + } 'ad app create' { $script:createCallCount++ @@ -223,6 +331,23 @@ function Invoke-AzJson { } } } + 'registration-lookup' { + switch ($command) { + 'ad app list' { + $displayNameIndex = [Array]::IndexOf($Arguments, '--display-name') + $displayName = [string] $Arguments[$displayNameIndex + 1] + + if ($displayName -ceq $legacyRegistrationDisplayName) { + return @($script:legacyRegistrationResult) + } + + return @() + } + 'ad app show' { + return $script:legacyRegistrationResult + } + } + } } throw "Unexpected mocked Azure CLI command: $($Arguments -join ' ')" @@ -241,6 +366,27 @@ function Reset-AzureContextMocks { } } +function Invoke-RestMethod { + param( + [Parameter(Mandatory)][string] $Uri, + [Parameter(Mandatory)][string] $Method, + [Parameter(Mandatory)][string] $Authentication, + [Parameter(Mandatory)][securestring] $Token, + [Parameter(Mandatory)][string] $ContentType, + [Parameter(Mandatory)][string] $InFile + ) + + $script:restMethodCalls += + [pscustomobject]@{ + Uri = $Uri + Method = $Method + Authentication = $Authentication + Token = $Token + ContentType = $ContentType + InFile = $InFile + } +} + function Reset-ResourceMocks { param([Parameter(Mandatory)][string] $Scenario) @@ -812,9 +958,38 @@ Invoke-TestCase 'clean and existing web apps use file-backed runtime configurati } } +Invoke-TestCase 'legacy registration lookup preserves the durable app during branding migration' { + $script:scenario = 'registration-lookup' + $script:azJsonCalls = @() + + $registration = Get-ExactAppRegistration + + Assert-Equal ` + -Actual ([string] $registration.appId) ` + -Expected ([string] $script:legacyRegistrationResult.appId) ` + -Because 'the existing technical-name registration must be reused rather than duplicated' + + $lookupNames = + @( + $script:azJsonCalls + | Where-Object { ($_.Arguments[0..2] -join ' ') -eq 'ad app list' } + | ForEach-Object { + $displayNameIndex = [Array]::IndexOf($_.Arguments, '--display-name') + [string] $_.Arguments[$displayNameIndex + 1] + } + ) + Assert-True ` + -Condition ($lookupNames.Count -eq 2 -and + $lookupNames -contains $registrationDisplayName -and + $lookupNames -contains $legacyRegistrationDisplayName) ` + -Because 'lookup must cover the canonical and one bounded legacy display name' +} + Invoke-TestCase 'restricted-tenant registration creation converges on existing state' { $script:scenario = 'registration' $script:azNoneCalls = @() + $script:azJsonCalls = @() + $script:restMethodCalls = @() $script:createCallCount = 0 $created = Ensure-AppRegistration -ExistingRegistration $null @@ -829,6 +1004,29 @@ Invoke-TestCase 'restricted-tenant registration creation converges on existing s -Expected ([string] $script:registrationResult.appId) ` -Because 'the existing-state path reuses the same registration' + $createCalls = @( + $script:azJsonCalls + | Where-Object { ($_.Arguments[0..2] -join ' ') -eq 'ad app create' } + ) + Assert-Equal ` + -Actual $createCalls.Count ` + -Expected 2 ` + -Because 'restricted-tenant creation retries exactly once' + + foreach ($call in $createCalls) { + $displayNameIndex = [Array]::IndexOf($call.Arguments, '--display-name') + Assert-Equal ` + -Actual ([string] $call.Arguments[$displayNameIndex + 1]) ` + -Expected $registrationDisplayName ` + -Because 'new registrations must start with the user-facing name' + + $homepageIndex = [Array]::IndexOf($call.Arguments, '--web-home-page-url') + Assert-Equal ` + -Actual ([string] $call.Arguments[$homepageIndex + 1]) ` + -Expected $viewerBaseUrl ` + -Because 'new registrations must start with the canonical homepage' + } + $updateCalls = @( $script:azNoneCalls | Where-Object { ($_.Arguments[0..2] -join ' ') -eq 'ad app update' } @@ -868,20 +1066,160 @@ Invoke-TestCase 'restricted-tenant registration creation converges on existing s -Actual ([string] $call.Arguments[$idTokenIndex + 1]) ` -Expected 'true' ` -Because 'Easy Auth requests code and id_token at its form-post callback' + + $displayNameIndex = + [Array]::IndexOf($call.Arguments, '--display-name') + Assert-Equal ` + -Actual ([string] $call.Arguments[$displayNameIndex + 1]) ` + -Expected $registrationDisplayName ` + -Because 'registration updates must reconcile the user-facing app name' + + $homepageIndex = + [Array]::IndexOf($call.Arguments, '--web-home-page-url') + Assert-Equal ` + -Actual ([string] $call.Arguments[$homepageIndex + 1]) ` + -Expected $viewerBaseUrl ` + -Because 'registration updates must reconcile the canonical homepage' + + $descriptionIndex = + [Array]::IndexOf($call.Arguments, '--set') + Assert-Equal ` + -Actual ([string] $call.Arguments[$descriptionIndex + 1]) ` + -Expected "description=$registrationDescription" ` + -Because 'registration updates must reconcile the plain-language description' + } + + Assert-Equal ` + -Actual $script:restMethodCalls.Count ` + -Expected 2 ` + -Because 'both clean and existing state must reconcile the PWA logo' + + foreach ($call in $script:restMethodCalls) { + Assert-Equal ` + -Actual ([string] $call.Uri) ` + -Expected "https://graph.microsoft.com/v1.0/applications/$($script:registrationResult.id)/logo" ` + -Because 'the logo must target the existing application object' + + Assert-Equal ` + -Actual ([string] $call.InFile) ` + -Expected $registrationLogoPath ` + -Because 'the logo upload must use the tracked Treemon PWA icon' + Assert-True ` + -Condition ($call.Method -ceq 'Put' -and + $call.ContentType -ceq 'image/png' -and + $call.Authentication -ceq 'Bearer' -and + (ConvertFrom-SecureString $call.Token -AsPlainText) -ceq 'fixture-graph-token') ` + -Because 'the logo must be uploaded as raw PNG bytes with an in-memory Graph token' + } + + $servicePrincipalUpdates = @( + $script:azNoneCalls + | Where-Object { ($_.Arguments[0..2] -join ' ') -eq 'ad sp update' } + ) + Assert-Equal ` + -Actual $servicePrincipalUpdates.Count ` + -Expected 2 ` + -Because 'both clean and existing state must reconcile Enterprise Application details' + + foreach ($call in $servicePrincipalUpdates) { + Assert-True ` + -Condition ($call.Arguments -contains "displayName=$registrationDisplayName" -and + $call.Arguments -contains "description=$registrationDescription" -and + $call.Arguments -contains "homepage=$viewerBaseUrl") ` + -Because 'the Enterprise Application must match the user-facing app registration' + } +} + +Invoke-TestCase 'Easy Auth requests only openid and rejects broader deployed scopes' { + $script:scenario = 'registration' + $script:azNoneCalls = @() + $workingDirectory = + Join-Path ([IO.Path]::GetTempPath()) "treemon-easy-auth-test-$([Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $workingDirectory | Out-Null + + try { + Ensure-EasyAuth ` + -AppRegistration $script:registrationResult ` + -TenantId 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' ` + -SubscriptionId '11111111-2222-3333-4444-555555555555' ` + -WorkingDirectory $workingDirectory + + $authCall = + @( + $script:azNoneCalls + | Where-Object { + ($_.Arguments[0..2] -join ' ') -eq 'rest --method put' -and + ($_.Arguments -join ' ') -match 'authsettingsV2' + } + ) + | Select-Object -First 1 + $bodyIndex = [Array]::IndexOf($authCall.Arguments, '--body') + $authSettingsPath = + ([string] $authCall.Arguments[$bodyIndex + 1]).TrimStart('@') + $authSettings = + Get-Content -LiteralPath $authSettingsPath -Raw | + ConvertFrom-Json + $loginParameters = + @($authSettings.properties.identityProviders.azureActiveDirectory.login.loginParameters) + + Assert-Equal ` + -Actual ($loginParameters -join '|') ` + -Expected $easyAuthLoginParameter ` + -Because 'Easy Auth must override its profile and email defaults with openid only' + Assert-Equal ` + -Actual ([bool] $authSettings.properties.login.tokenStore.enabled) ` + -Expected $false ` + -Because 'openid-only authentication must not enable provider token storage' + + Assert-EasyAuthConfiguration ` + -AppRegistration $script:registrationResult ` + -TenantId 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' ` + -AuthSettings $authSettings + + $authSettings.properties.identityProviders.azureActiveDirectory.login.loginParameters = + @('scope=openid profile email') + $rejectedDefaultScopes = $false + + try { + Assert-EasyAuthConfiguration ` + -AppRegistration $script:registrationResult ` + -TenantId 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' ` + -AuthSettings $authSettings + } catch { + $rejectedDefaultScopes = + $_.Exception.Message -match 'openid-only' + } + + Assert-True ` + -Condition $rejectedDefaultScopes ` + -Because 'deployed-state verification must reject App Service default profile and email scopes' + } finally { + Remove-Item -LiteralPath $workingDirectory -Recurse -Force -ErrorAction SilentlyContinue } } Invoke-TestCase 'deployed registration requires ID tokens without browser access tokens' { Assert-AppRegistrationAuthenticationFlow ` -AppRegistration $script:registrationResult + Assert-AppRegistrationBranding ` + -AppRegistration $script:registrationResult + Assert-ServicePrincipalBranding ` + -ServicePrincipal $script:servicePrincipalResult $invalidRegistration = [pscustomobject]@{ + id = $script:registrationResult.id appId = $script:registrationResult.appId - displayName = $Registration + displayName = $registrationDisplayName + description = $registrationDescription passwordCredentials = @() + requiredResourceAccess = @() signInAudience = 'AzureADMyOrg' + info = [pscustomobject]@{ + logoUrl = 'https://aadcdn.msftauthimages.net/logo.png' + } web = [pscustomobject]@{ + homePageUrl = $viewerBaseUrl redirectUris = @($callbackUrl) implicitGrantSettings = [pscustomobject]@{ enableAccessTokenIssuance = $false @@ -918,6 +1256,59 @@ Invoke-TestCase 'deployed registration requires ID tokens without browser access Assert-True ` -Condition $rejectedBrowserAccessToken ` -Because 'deployed-state verification must keep browser access-token issuance disabled' + + $invalidRegistration.web.implicitGrantSettings.enableAccessTokenIssuance = $false + $invalidRegistration.requiredResourceAccess = + @([pscustomobject]@{ resourceAppId = '00000003-0000-0000-c000-000000000000' }) + $rejectedApiPermissions = $false + + try { + Assert-AppRegistrationAuthenticationFlow ` + -AppRegistration $invalidRegistration + } catch { + $rejectedApiPermissions = + $_.Exception.Message -match 'declares API permissions' + } + + Assert-True ` + -Condition $rejectedApiPermissions ` + -Because 'the viewer registration must remain free of Microsoft Graph and other API permissions' + + $invalidRegistration.requiredResourceAccess = @() + $invalidRegistration.description = 'technical placeholder' + $rejectedBranding = $false + + try { + Assert-AppRegistrationBranding ` + -AppRegistration $invalidRegistration + } catch { + $rejectedBranding = + $_.Exception.Message -match 'canonical name, description, homepage, or logo' + } + + Assert-True ` + -Condition $rejectedBranding ` + -Because 'deployed-state verification must reject incomplete user-facing app details' + + $invalidServicePrincipal = + [pscustomobject]@{ + displayName = $legacyRegistrationDisplayName + description = $registrationDescription + homepage = $viewerBaseUrl + } + $rejectedEnterpriseAppBranding = $false + + try { + Assert-ServicePrincipalBranding ` + -ServicePrincipal $invalidServicePrincipal + } catch { + $rejectedEnterpriseAppBranding = + $_.Exception.Message -match 'Enterprise application' + } + + Assert-True ` + -Condition $rejectedEnterpriseAppBranding ` + -Because 'deployed-state verification must reject a stale Enterprise Application name' } Write-Host 'Canvas share deployment regression tests passed.' diff --git a/scripts/canvas-share-viewer-deployment/treemon-canvas-viewer-logo.png b/scripts/canvas-share-viewer-deployment/treemon-canvas-viewer-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ec882ca02e1b0fae4a8f11eb826c9cd9ba37d98a GIT binary patch literal 79956 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 literal 0 HcmV?d00001 diff --git a/scripts/deploy-canvas-share-viewer.ps1 b/scripts/deploy-canvas-share-viewer.ps1 index cadd5031..1ed65f90 100644 --- a/scripts/deploy-canvas-share-viewer.ps1 +++ b/scripts/deploy-canvas-share-viewer.ps1 @@ -22,10 +22,6 @@ param( [ValidateNotNullOrEmpty()] [string] $Identity, - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $Registration, - [switch] $ValidateOnly ) From f7c81a4b321a021fce434c6c39b68820d2d9ffdf Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Thu, 27 Aug 2026 15:48:12 +0200 Subject: [PATCH 25/27] Simplify canvas viewer provisioning and streaming - remove checked-in Azure deployment reconciliation and consolidate its specs - src/CanvasShareViewer/BlobStorage.fs:99 - stream content instead of buffering - src/Server/CanvasShare.fs:143 - make share prefixes a distinct type - src/Tests/CanvasShareTests.fs:103 - remove redundant sharing tests - src/Tests/CanvasShareViewerTests.fs:163 - share viewer host lifecycle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 6 - docs/canvas-share-viewer-deployment.md | 183 --- docs/spec/canvas-sharing.md | 178 +-- docs/spec/future/code-improvements.md | 1 - docs/spec/process-execution.md | 2 +- docs/spec/remoting-csrf-hardening.md | 140 -- docs/spec/session-status-push.md | 2 +- docs/spec/worktree-monitor.md | 18 +- scripts/canvas-share-lifecycle-policy.json | 20 - .../canvas-share-viewer-deployment/Azure.ps1 | 1304 ---------------- .../canvas-share-viewer-deployment/Common.ps1 | 397 ----- .../Deployment.Tests.ps1 | 1314 ----------------- .../SubscriptionGuard.ps1 | 114 -- .../TestHarness.ps1 | 32 - .../ViewerBlobAccess.Tests.ps1 | 282 ---- .../ViewerBlobAccess.ps1 | 226 --- .../treemon-canvas-viewer-logo.png | Bin 79956 -> 0 bytes scripts/deploy-canvas-share-viewer.ps1 | 173 --- src/CanvasShareViewer/BlobStorage.fs | 16 +- src/CanvasShareViewer/ShareLookup.fs | 30 +- src/CanvasShareViewer/ViewerApplication.fs | 24 +- src/Server/CanvasShare.fs | 19 +- src/Tests/CanvasShareTests.fs | 59 +- ...CanvasShareViewerContainmentTestHelpers.fs | 18 +- src/Tests/CanvasShareViewerTests.fs | 208 ++- 25 files changed, 366 insertions(+), 4400 deletions(-) delete mode 100644 docs/canvas-share-viewer-deployment.md delete mode 100644 docs/spec/remoting-csrf-hardening.md delete mode 100644 scripts/canvas-share-lifecycle-policy.json delete mode 100644 scripts/canvas-share-viewer-deployment/Azure.ps1 delete mode 100644 scripts/canvas-share-viewer-deployment/Common.ps1 delete mode 100644 scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 delete mode 100644 scripts/canvas-share-viewer-deployment/SubscriptionGuard.ps1 delete mode 100644 scripts/canvas-share-viewer-deployment/TestHarness.ps1 delete mode 100644 scripts/canvas-share-viewer-deployment/ViewerBlobAccess.Tests.ps1 delete mode 100644 scripts/canvas-share-viewer-deployment/ViewerBlobAccess.ps1 delete mode 100644 scripts/canvas-share-viewer-deployment/treemon-canvas-viewer-logo.png delete mode 100644 scripts/deploy-canvas-share-viewer.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa7ab206..8ac49b6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,11 +38,5 @@ jobs: - name: Test extensions run: npm run test:extension - - name: Test canvas share deployment - shell: pwsh - run: | - ./scripts/canvas-share-viewer-deployment/ViewerBlobAccess.Tests.ps1 - ./scripts/canvas-share-viewer-deployment/Deployment.Tests.ps1 - - name: Test .NET run: dotnet test src/Tests/Tests.fsproj --filter "Category!=Local" --no-build --verbosity normal diff --git a/docs/canvas-share-viewer-deployment.md b/docs/canvas-share-viewer-deployment.md deleted file mode 100644 index f981547d..00000000 --- a/docs/canvas-share-viewer-deployment.md +++ /dev/null @@ -1,183 +0,0 @@ -# Canvas Share Viewer Deployment - -This is a local operator workflow for an isolated, non-production Azure subscription. It creates -or reconciles the canonical viewer at exactly: - -```text -https://treemon.azurewebsites.net -``` - -The App Service name is fixed as `treemon`. The automation checks global name availability before -the first creation and stops if the name is unavailable; it never chooses a suffix or a custom -domain. - -## Prerequisites - -- PowerShell 7.4 or later, Azure CLI 2.72.0 or later, and .NET SDK 10 or later. -- Azure CLI signed in as the delegated publisher user, with the requested personal development - subscription selected: - - ```powershell - az login --tenant '' - az account set --subscription '' - ``` - -- Azure permissions to create resources, update the storage account, assign roles at the Blob - container scope, and disable App Service basic publishing credentials. Entra permissions must - allow creation or ownership of the dedicated app registration and its federated credential, - updating the matching Enterprise Application, and uploading the registration logo through - Microsoft Graph. - The operator must also be able to list the viewer identity's role assignments throughout the - subscription and inherited parent scopes and read their role definitions. -- If the tenant enforces `serviceManagementReference` on app-registration changes, the signed-in - Azure CLI user must own applications that expose exactly one distinct non-empty reference value. - The script discovers that value only after Entra returns the specific required-field error; it - never invents, prints, or asks for a service reference, and fails when publisher-owned state is - absent or ambiguous. -- An existing storage account configured in the machine-level Treemon config. The container may be - omitted to use `canvas-shared`; the deployment creates it through the ARM control plane when - needed: - - ```json - { - "canvasShare": { - "approvedSubscription": "", - "accountName": "", - "container": "canvas-shared", - "defaultExpiryDays": 7 - } - } - ``` - -The script reads the approved subscription, storage account, and container from -`~/.treemon/config.json` (or `$TREEMON_CONFIG_DIR/config.json`) and uses the current Azure CLI user -as the publisher. The `-Subscription` argument and selected Azure CLI account must both exactly -match the machine-private approved value before the script performs any resource-provider or Entra -application operation. Exact subscription and tenant identifiers remain local and are never -committed. Storage and publisher identifiers are therefore not additional command-line inputs. - -## Validate without changing Azure - -Run the read-only validation first: - -```powershell -.\scripts\deploy-canvas-share-viewer.ps1 ` - -Subscription '' ` - -Tenant '' ` - -ResourceGroup '' ` - -Plan '' ` - -Identity '' ` - -ValidateOnly -``` - -Validation first checks the requested and selected subscription against the machine-private -approved target. A missing or mismatched value fails before any resource-provider or Entra -application operation. It then checks the tenant, storage configuration, existing resources, -global availability of `treemon` when the app does not yet exist, lifecycle-policy invariant, and -a local Release publish of the viewer. If the requested managed identity already exists, validation -also resolves its direct and inherited role assignments and fails when any effective Blob-read -data action is scoped outside the configured share container. Group-derived assignments are -included. It performs no Azure mutation and does not write machine configuration. - -## Provision and deploy - -Remove `-ValidateOnly` to apply the same plan: - -```powershell -.\scripts\deploy-canvas-share-viewer.ps1 ` - -Subscription '' ` - -Tenant '' ` - -ResourceGroup '' ` - -Plan '' ` - -Identity '' -``` - -The script is idempotent and is intended to be run a second time with the same values. It: - -1. Creates or reuses the non-production resource group, a B1 Linux App Service plan, the - user-assigned managed identity, and the fixed-name App Service. -2. Disables account-level Blob public access, creates the configured private container, and grants - the viewer `Storage Blob Data Reader` and the current publisher - `Storage Blob Data Contributor`, both at that container's exact ARM scope. Before mutating an - existing deployment and again during final verification, it rejects broader viewer Blob-read - assignments discovered anywhere in the subscription or inherited from a parent scope. -3. Creates or reuses the secret-free, current-tenant `AzureADMyOrg` app registration and service - principal named **Treemon Canvas Viewer**. A bounded lookup recognizes the former - `treemon-canvas-viewer-auth` name so the existing registration is renamed rather than duplicated. - The registration uses the canonical viewer URL as its homepage, carries a plain-language - read-only description, and uploads a tracked 215x215 PNG derived from the Treemon PWA icon. It - accepts only the canonical App Service callback and declares no Graph or other API permissions. On a restricted - tenant's specific `serviceManagementReference` error, creation or update retries with the one - unambiguous reference already carried by applications the delegated publisher owns. It enables - ID-token issuance for Easy Auth's `code id_token` form-post callback while leaving browser - access-token issuance disabled. -4. Adds a federated credential whose subject is the managed identity principal. Easy Auth uses the - slot-sticky `OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID` setting and its - `clientSecretSettingName` sentinel, so no client secret is created. -5. Requires Easy Auth before requests reach the viewer, uses the tenant's v2 issuer, explicitly - requests only the `openid` scope, requires HTTPS, disables the token store, and pins both the - .NET host and ASP.NET Core environments to `Production`. This removes App Service's unused - default `profile` and `email` requests. Microsoft Entra can still display its generic - "Maintain access to data you have given it access to" consent line even though the request omits - `offline_access` and the disabled token store cannot retain provider refresh tokens. -6. Merges `expire-shared-canvas-docs` into the storage account's complete lifecycle policy while - preserving unrelated rules. Deletion starts only after more than 31 days, beyond the 30-day - maximum share lifetime. -7. Supplies the pipe-bearing Linux runtime through Azure CLI's JSON-file configuration input, - avoiding command reinterpretation by the Windows `az.cmd` launcher. It then disables FTP and SCM - basic publishing credentials, builds a ZIP locally, and deploys it with `az webapp deploy`. - Azure CLI therefore uses Microsoft Entra authentication rather than a deployment credential. -8. Verifies the resulting control-plane configuration and then atomically sets: - - ```json - { - "canvasShare": { - "viewerBaseUrl": "https://treemon.azurewebsites.net" - } - } - ``` - - Existing `canvasShare` fields and unrelated machine-level settings are preserved. - - Run the apply step while no Treemon instance is writing machine configuration -- the script - reads, updates, and atomically replaces `config.json` itself rather than going through a - running server, so a settings change made in the UI at the same moment could be overwritten. - -The automation does not invoke Treemon server lifecycle commands and does not bind any local -Treemon port. Temporary build and JSON files are deleted on exit. It requests one Microsoft Graph -token in memory to upload the app logo but never prints or persists it. It creates no deployment -credentials and does not read a publishing profile. - -## After deployment - -Federated credentials and Blob role assignments can take several minutes to propagate. A first -sign-in or Blob read that fails immediately after provisioning may return the viewer's empty 503 -response and should be retried after propagation; do not replace the managed-identity federation -with a client secret. - -If the containment check reports an assignment ID, role, and scope, remove that assignment or -select a truly dedicated viewer identity. The script deliberately does not delete it because it -may authorize another workload. Conditions on broader assignments are not accepted as proof of -container-only access. - -By default, every identity in the current workforce tenant can authenticate. If a smaller audience -is required, enable enterprise-application assignment and assign the intended users or groups in -Entra; keep the app registration single-tenant. - -The canonical App Service, plan, identity, app registration/service principal, federated -credential, RBAC assignments, and lifecycle policy are durable non-production resources. Do not -tear them down after verification. Verification cleanup is limited to uploaded document fixtures -and auxiliary resources created solely for permission-boundary probes. - -Useful read-only checks are: - -```powershell -az webapp show --name treemon --resource-group '' - -az rest --method get ` - --uri '/subscriptions//resourceGroups//providers/Microsoft.Web/sites/treemon/config/authsettingsV2?api-version=2023-12-01' - -az storage account management-policy show ` - --account-name '' ` - --resource-group '' -``` diff --git a/docs/spec/canvas-sharing.md b/docs/spec/canvas-sharing.md index 5f73aaab..d5a888c1 100644 --- a/docs/spec/canvas-sharing.md +++ b/docs/spec/canvas-sharing.md @@ -40,13 +40,12 @@ action while the other owns the clipboard, and both controls are disabled until that workflow settles, so their async results cannot overwrite the pane-global error or clipboard notice state. - Sharing operates on a single, self-contained doc. Docs that link to sibling `.html` tabs are - shared as just the focused file; those links remain inert in the exported copy, unchanged from - today. + shared as just the focused file; those links are inert in the exported copy. ### Static export -- The static export (`CanvasExport.buildStaticHtml`) is unchanged: the on-disk - `.agents/canvas/.html` is already free of the serve-time injected scripts (bridge heartbeat, +- The static export (`CanvasExport.buildStaticHtml`) starts from the on-disk + `.agents/canvas/.html`, which is free of the serve-time injected scripts (bridge heartbeat, `canvasSend`, idiomorph/morph, error overlay), so the export re-injects only the shared base theme `