diff --git a/docs/decisions/0008-public-app-api.md b/docs/decisions/0008-public-app-api.md index cc63faf..a8d1f89 100644 --- a/docs/decisions/0008-public-app-api.md +++ b/docs/decisions/0008-public-app-api.md @@ -2,7 +2,8 @@ - Status: Accepted - Date: 2026-08-08 -- Related: [ADR-0003](0003-jsonrpc-primary-transport.md) (why this is not a JSON-RPC handler). +- Related: [ADR-0003](0003-jsonrpc-primary-transport.md) (why this is not a JSON-RPC handler), + [ADR-0011](0011-user-scoped-tenant-discovery.md) (amends the self-only invariant). ## Context diff --git a/docs/decisions/0009-public-management-api.md b/docs/decisions/0009-public-management-api.md index 6f4bdbb..c3bbcb1 100644 --- a/docs/decisions/0009-public-management-api.md +++ b/docs/decisions/0009-public-management-api.md @@ -113,6 +113,7 @@ and a single `StackTemplate`.** Deferred to central-auth Phase 2, which can add service identities *behind the same grant model* rather than beside it. - **Put the endpoints under `/api/app/*`.** Convenient — same token, same middleware — but it would - destroy that surface's one-sentence security contract, *a stack can only ever see itself*. Reviewers + destroy that surface's one-sentence security contract, *a stack can only ever see itself* (since + amended by [ADR-0011](0011-user-scoped-tenant-discovery.md) for proven visiting users). Reviewers and operators rely on that invariant to reason about the App API without reading the code; a surface where some tokens see other stacks is a different thing and gets a different prefix. diff --git a/docs/decisions/0011-user-scoped-tenant-discovery.md b/docs/decisions/0011-user-scoped-tenant-discovery.md new file mode 100644 index 0000000..7def836 --- /dev/null +++ b/docs/decisions/0011-user-scoped-tenant-discovery.md @@ -0,0 +1,133 @@ +# ADR-0011: A stack may ask which sibling tenants the proven visiting user can reach + +- Status: Accepted +- Date: 2026-08-10 +- Related: [ADR-0008](0008-public-app-api.md) (the invariant this amends), + [ADR-0009](0009-public-management-api.md) (the grant model this sits beside), and the + [central-auth design](../central-auth/design.md) (the assertion this trusts). + +## Context + +With central authorization enabled, Watchtower is the identity provider for the apps it proxies: it +owns the users, decides per route who may enter, and forwards a short-lived ES256 assertion +(`X-Watchtower-Jwt`, `aud` = the app's domain) to the upstream on every allowed request. A product +deployed as a template with one tenant stack per customer therefore has a proven user identity in +hand on every request it serves — and no way to act on it beyond the current tenant. + +The missing feature is mundane and universally expected: a **tenant switcher**. A consultant who +works for three of the vendor's customers, or an employee whose company runs a staging tenant +alongside production, lands on one tenant and has no idea the others exist, let alone a menu to +switch to them. The vendor's own management UI wants the same list, filtered to the person looking +at it, so a support agent sees the customers they may actually open. + +Nothing on the existing surfaces answers it. The App API ([ADR-0008](0008-public-app-api.md)) is +self-only by construction — *a stack can only ever see itself*. The Management API +([ADR-0009](0009-public-management-api.md)) can list a template's tenants, but its authority is an +operator-created grant, and ADR-0009 explicitly **forbids granting a template to one of its own +tenants**: that is the one thing that would let a customer enumerate its neighbours. The product's +tenant stack is exactly that forbidden caller, and it is also the only place the switcher can live — +the switcher belongs in the product's own UI, on the customer's own domain, not in Watchtower's. + +The information itself is not a secret from the *user*. Somebody who may enter `customer4` and +`customer7` can discover both by visiting them; the access decision is unchanged either way. What is +missing is a way for the product to ask **on that user's behalf** without the question degenerating +into an enumeration oracle — an endpoint that answers "may user 42 reach these tenants?" for an +arbitrary `42` would hand any token-holding stack a probe against every user on the host. + +## Decision + +**Add a user-scoped tenant-discovery read to both public surfaces — +`GET /api/app/tenants/accessible` and `GET /api/mgmt/templates/{templateId}/tenants/accessible` — +answered only for a user the caller can *prove* is currently visiting it, by forwarding that user's +Watchtower-signed identity JWT.** The listing segment is carved out of the tenant-slug namespace to +make that second path unambiguous: `accessible` becomes a reserved slug, so no tenant can ever be +provisioned that `…/tenants/{slug}` would resolve on top of it. + +This is a deliberate, scoped amendment of ADR-0008's invariant. A stack still cannot see its +siblings on its own account; it may learn **what the authenticated user standing in front of it may +reach** among them. + +- **The proof is the assertion; no endpoint ever accepts a user id.** The caller forwards the + `X-Watchtower-Jwt` verbatim from the request it is currently serving, alongside its own + `Authorization: Bearer wtapp_…`. There is no `?userId=` parameter, no email lookup, no subject in + the body — the only way to name a user is to hold a live, valid assertion about them. +- **The audience binding is the anti-enumeration control.** Validation is the existing ES256 chain + (algorithm pinned, signature, issuer, expiry) **plus** `aud` matching one of the *calling stack's + own route domains*. The assertion Watchtower mints for `customer4.example.com` is accepted only by + the stack serving `customer4.example.com`. So an app can only ask about users **actively visiting + it** — an assertion captured elsewhere, by a curious tenant or a compromised one, carries the + wrong audience and is refused. On the Management API the same rule applies against the + *management* stack's route domains. +- **The user row is rechecked, not taken from the token.** After signature and audience pass, the + user is reloaded and a missing or `Disabled` account is refused. An assertion minted moments + before an operator disabled the account does not keep working for the remainder of its five-minute + life. +- **Access is evaluated with the same route policy the proxy enforces**, in one pass over the + template's tenants rather than per-tenant probing: `Public` and `Authenticated` routes are + reachable, `Restricted` needs a grant for that user, and an unrecognised mode fails closed. +- **Every assertion failure is one generic `401` with one message.** Missing, malformed, expired, + wrong audience, wrong signature, disabled user — indistinguishable to the caller. A per-check + message would turn the endpoint back into the oracle the audience binding exists to prevent. +- **The response is the minimum a switcher needs**: slug, domain, and (App API only) which entry is + the caller itself, sorted by slug. No stack ids, no deploy status, no timestamps — unlike every + other response on these surfaces, this one is rendered to end users. +- **Each surface keeps its own authorization semantics ahead of the assertion.** On the App API the + caller must be a tenant of a template (otherwise `404` — it is telling the caller about itself). + On the Management API the ADR-0009 grant chain runs **first**, so an ungranted template is still a + uniform `404` and never reveals that an assertion would have been checked. +- **With `Auth:Enabled` off, both endpoints are `404`.** No assertion can exist, so there is no + honest answer to give; this matches how the verify and UserInfo endpoints already disappear. + +## Consequences + +- **The switcher is only meaningful where central auth fronts the tenant routes.** A product whose + routes are `Public` — it does its own login — never receives an assertion, so it gets `404` with + auth off and `401` with auth on, and must keep its own user-to-tenant mapping product-side, as it + does today. Adopting the endpoint is therefore a reason to move a product's routes onto + `Authenticated`/`Restricted`, not something that works everywhere by default. +- **ADR-0008's invariant now has exactly one exception, and it must be quoted with it.** "A stack + can only ever see itself" remains the rule for everything the stack asks on its own behalf; the + one carve-out is this user-scoped read. Reviewers who rely on the one-sentence contract need the + amended sentence, so the public API docs state it inline rather than only here. +- **A tenant learns that siblings exist — the ones its own visitor can reach.** Accepted: the user + could establish the same list by visiting those domains, so the endpoint discloses to the tenant + only what the person already sitting in its UI could hand it anyway. What it does not do is reveal + tenants the user *cannot* reach, or anything about users who are not there. The residual exposure + is a tenant harvesting sibling slugs from its own visitors over time; that is bounded by who + visits it, and it is why the response carries no operational data about those siblings. +- **Debuggability is deliberately poor.** An integrator whose calls `401` cannot tell a clock-skewed + expiry from a misconfigured audience from a disabled account. The operator-side audit trail and + logs are the diagnostic path; the API surface stays silent on purpose. +- **Grant changes take effect immediately, token changes do not.** Access is evaluated per request + against the live grant rows, so revoking a user's access to a sibling removes it from the next + response. The five-minute assertion window is closed for account disablement by the user recheck; + nothing else about the user is read from the token. +- **No caching, no pagination.** A handful of indexed lookups per call, none of them per-tenant, over + the tenant count of a single template. If a template ever grows large enough for this to matter, + the shape can take a page token without changing the security model. + +### Rejected alternatives + +- **Accept a bare user id (or email) parameter.** The obvious API, and the reason this ADR exists. + Any stack holding a valid `wtapp_` token could then ask "which of these tenants may user 42 + reach?" for every id on the host — an enumeration oracle over the user table *and* over sibling + access policy, driven by a credential that is deliberately low-value and lives in a container's + environment. Requiring an unforgeable assertion makes the answer's scope structural instead of + policed. +- **Reuse the existing no-audience assertion validation** used by verify and UserInfo, where any + Watchtower-signed JWT is accepted. It would make one line of code do both jobs, and it would + destroy the property that matters: a tenant could replay an assertion it received on *its* domain + against another tenant's endpoint, or a hostile app could farm assertions from visitors and use + them to map the estate. The audience is what binds the question to "a user who is here, now". The + no-audience overload keeps its current behavior for the flows that legitimately have no single + audience. +- **Put the accessible-tenant list in the JWT claims.** Zero new endpoints, zero new auth — but the + assertion is minted per request for one route and lives five minutes, so the list would be stale + the moment a grant changed, and every proxied request to every protected app would carry a claim + almost none of them read. It also leaks the list to apps that never asked, through a header that + crosses into upstreams the user did not choose. +- **Expose it only on the Management API.** Safe and already grant-governed, but it puts the + switcher in the wrong place: the vendor's management UI is not where a customer's user switches + tenants. The product's own UI, on the tenant's own domain, is — and ADR-0009 rightly refuses to + grant that stack management of its own template. The management variant ships too, for the + support-agent view, but it cannot be the only one. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index c7d1edd..bebffb2 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -30,3 +30,4 @@ live here. - [ADR-0008: Deployed applications query themselves through a token-authenticated REST API](0008-public-app-api.md) - [ADR-0009: A management stack manages one template's tenants through a granted REST API](0009-public-management-api.md) - [ADR-0010: The future runtime target is the Kubernetes API, via KubeSolo](0010-target-kubesolo-runtime.md) +- [ADR-0011: A stack may ask which sibling tenants the proven visiting user can reach](0011-user-scoped-tenant-discovery.md) diff --git a/docs/public-app-api.md b/docs/public-app-api.md index 85c5c59..f2c339c 100644 --- a/docs/public-app-api.md +++ b/docs/public-app-api.md @@ -11,6 +11,10 @@ JSON-RPC admin API. > **A stack can only ever see itself.** Container ids are never accepted from the caller: every > Docker lookup is resolved server-side from the authenticated stack's compose project label. No > response contains deploy output, environment variable values, credentials, or another stack's data. +> The one exception is [`/tenants/accessible`](#get-apiapptenantsaccessible) +> ([ADR-0011](decisions/0011-user-scoped-tenant-discovery.md)), which answers *on behalf of a proven +> visiting user* — never on the stack's own account — and only about siblings that user could reach +> by typing their domains anyway. ## Authentication @@ -41,9 +45,9 @@ Every endpoint can return these: | --- | --- | | `200` | Success. | | `400` | Only on `/logs`: the stack exposes several services and no `service` was given. The body lists them. | -| `401` | Missing, malformed, or unknown token. | +| `401` | Missing, malformed, or unknown token — or, on `/tenants/accessible`, an unusable user assertion. | | `403` | Valid token, but the App API is switched off for that stack. | -| `404` | Only on `/logs`: no container matches (the stack is down, or the named service does not exist). | +| `404` | On `/logs`: no container matches (the stack is down, or the named service does not exist). On `/tenants/accessible`: the caller is not a tenant, or central auth is off. | | `503` | The Docker daemon is unreachable, so live container state could not be read. Affects `/status`, `/version` and `/logs`. | `/self` and `/deployments` read only Watchtower's own database, so they keep working while Docker is @@ -263,6 +267,67 @@ data: So a well-behaved client treats `error` as "this log source is gone" and `done` as "the stream is over", and should expect zero or more `error` frames before `done`. +### `GET /api/app/tenants/accessible` + +Which sibling tenants may **the user currently visiting you** open? This is the tenant switcher: you +are a tenant of a template, the person in front of you may have access to some of your siblings, and +this endpoint tells you which — so your UI can render the menu. + +It is the one place the App API answers about stacks other than yourself, and it does so only for a +user you can *prove* is there. That proof is the [central-auth](central-auth/README.md) identity +assertion, so this endpoint takes **two** headers: + +| Header | Value | +| --- | --- | +| `Authorization` | `Bearer wtapp_…` — your own App API token, with the usual `401`/`403` meanings. | +| `X-Watchtower-Jwt` | The assertion from the request **you are currently serving**, forwarded verbatim. | + +```json +{ + "tenants": [ + { "slug": "customer4", "domain": "customer4.example.com", "current": true }, + { "slug": "customer7", "domain": "customer7.example.com", "current": false } + ] +} +``` + +Tenants are sorted by `slug` ascending. Only tenants the user may actually reach are listed: a +`Public` or `Authenticated` sibling always, a `Restricted` one only when that user holds a grant on +it. A sibling with no primary route is omitted, since there is nothing to switch to. Deliberately +absent: stack ids, deploy status, timestamps — this payload is rendered to end users, so it carries +nothing operational. + +**`current` marks the calling stack when it appears** — and your own row is filtered exactly like +any other, so it is **absent from the list if the visitor cannot reach your own primary domain**. +That is not a hypothetical: a `Restricted` primary route whose visitor arrived through a different +domain of yours, or a stack with no primary route at all, both produce a list with no `current: true` +entry. Do not assume one exists; render the switcher from the list you got, and fall back to what +you already know about yourself. + +| Situation | Response | +| --- | --- | +| Your stack is not a tenant of a template | `404` `{"error":"This stack is not a tenant of a template."}` | +| Central auth is switched off on this Watchtower | `404` | +| The assertion is missing, expired, tampered with, minted for another domain, or names a disabled user | `401` `{"error":"Missing or invalid user assertion."}` | + +**One message covers every assertion failure**, on purpose: a caller must not be able to tell "this +token is expired" from "this token is not for you" from "that account is disabled". If you are +debugging an integration, check the obvious three first — that you forwarded the header unchanged, +that the request really came through the proxy, and that your clock is right. + +**Why the `aud` matters.** The assertion Watchtower mints is bound to the domain it was issued for +(`aud` = the app's domain), and this endpoint accepts it only if that audience is one of **your own +route domains**. So you can ask about a user who is visiting *you*, and nobody can ask about a user +who is visiting *them*: an assertion collected on another app's domain is a `401` here, no matter +how valid it is elsewhere. That binding is what keeps the endpoint from becoming a way to probe +which tenants an arbitrary user can reach — see +[ADR-0011](decisions/0011-user-scoped-tenant-discovery.md). + +`404` when central auth is off is the honest answer rather than a courtesy: with no proxy issuing +assertions there is no user to prove, so there is no question to answer. Products whose routes are +`Public` — they run their own login — never receive an assertion either, and should keep their own +user-to-tenant mapping. + ## Examples Using the variables the application already has in its environment: @@ -287,6 +352,12 @@ curl -sS -N -H "Authorization: Bearer $WATCHTOWER_APP_TOKEN" \ # Follow the log stream curl -sS -N -H "Authorization: Bearer $WATCHTOWER_APP_TOKEN" \ "$WATCHTOWER_URL/api/app/logs?service=worker&follow=true" + +# Tenant switcher: where else may THIS visitor go? +# $VISITOR_JWT is the X-Watchtower-Jwt of the request you are serving — not a stored value. +curl -sS -H "Authorization: Bearer $WATCHTOWER_APP_TOKEN" \ + -H "X-Watchtower-Jwt: $VISITOR_JWT" \ + "$WATCHTOWER_URL/api/app/tenants/accessible" ``` Consuming the stream from JavaScript — note that `EventSource` cannot send an `Authorization` diff --git a/docs/public-mgmt-api.md b/docs/public-mgmt-api.md index 49e2eee..3545033 100644 --- a/docs/public-mgmt-api.md +++ b/docs/public-mgmt-api.md @@ -64,9 +64,9 @@ stops that tenant querying itself, but does not hide it from, or protect it agai | `201` | Tenant created (`POST …/tenants`). | | `202` | Deploy accepted and queued (`POST …/deploy`). | | `400` | Invalid slug, invalid env variable name, malformed body; on `/logs`, several services and no `service`. | -| `401` | Missing, malformed, or unknown token. | +| `401` | Missing, malformed, or unknown token; on `/tenants/accessible`, an unusable user assertion. | | `403` | App API switched off for your stack, or `DELETE` without `allowDelete`. | -| `404` | Unknown/ungranted template, unknown tenant slug; on `/logs`, no container matches. | +| `404` | Unknown/ungranted template, unknown tenant slug; on `/logs`, no container matches; on `/tenants/accessible`, central auth is off. | | `409` | Slug, domain, or stack name already taken; `DELETE` while a deploy is queued or running. | | `500` | Only on `DELETE`: the compose teardown failed. Nothing was deleted; the call is safe to retry. | | `503` | The Docker daemon is unreachable, so live container state could not be read. | @@ -114,7 +114,11 @@ curl -sS -X POST "$WATCHTOWER_URL/rpc" -H 'Content-Type: application/json' -d '{ ## Endpoints All responses are JSON except `/logs`, which is a Server-Sent-Event stream. Every path is scoped by -`{templateId}`, and every one of them `404`s unless you hold a grant on that template. +`{templateId}`, and every one of them `404`s unless you hold a grant on that template. Two of them +list tenants and they answer different questions: [`…/tenants`](#get-apimgmttemplatestemplateidtenants) +is the **operations view** — every tenant of the template, whoever is asking — while +[`…/tenants/accessible`](#get-apimgmttemplatestemplateidtenantsaccessible) is **one user's view**, +filtered to the tenants a named, proven visitor may actually open. ### `GET /api/mgmt/templates` @@ -161,6 +165,55 @@ Every tenant of the template, newest first. `lastDeployStatus` is `null` for a tenant that has never deployed. Every status value in this API comes from the same vocabulary as the App API: `queued`, `running`, `success`, `failed`. +### `GET /api/mgmt/templates/{templateId}/tenants/accessible` + +The same template's tenants, but **filtered to what one visiting user may reach** — the support +agent's view, and the management-stack counterpart to +[`GET /api/app/tenants/accessible`](public-app-api.md#get-apiapptenantsaccessible). Where +`…/tenants` answers "which customers exist", this answers "which of them may the person in front of +me open". + +The user is never named by id. You prove them with the [central-auth](central-auth/README.md) +identity assertion from the request your UI is currently serving, so this endpoint takes a second +header alongside your bearer token: + +``` +X-Watchtower-Jwt: +``` + +```json +{ + "tenants": [ + { "slug": "customer4", "domain": "customer4.example.com" }, + { "slug": "customer7", "domain": "customer7.example.com" } + ] +} +``` + +Sorted by `slug` ascending. There is no `current` field — a management stack is not one of the +tenants it lists, so the App API's "this one is me" marker has nothing to mark. `Public` and +`Authenticated` tenants are always listed; a `Restricted` one only when that user holds a grant on +it; a tenant with no primary route is omitted. As on the App API, the payload is meant to be +rendered to a person, so it carries no stack ids, status, or timestamps. + +**The grant comes first.** Ordinary [`404` semantics](#authentication) are unchanged and are decided +*before* the assertion is looked at: a template you were not granted — or that does not exist — is a +`404`, so this endpoint tells you nothing new about the host. Only once the grant holds is the +assertion checked. + +| Situation | Response | +| --- | --- | +| Unknown or ungranted template | `404` (as everywhere else here) | +| Central auth is switched off on this Watchtower | `404` | +| The assertion is missing, expired, tampered with, minted for another domain, or names a disabled user | `401` `{"error":"Missing or invalid user assertion."}` | + +**The audience must be yours.** An assertion is bound to the domain it was issued for, and this +endpoint accepts it only when that `aud` is one of the **management stack's own route domains** — +the domain your UI is served on, not the tenant's. So you can ask about somebody visiting your +management UI, and nothing lets you ask about somebody visiting a tenant. One generic `401` covers +every assertion failure, deliberately: the endpoint must not report *which* check failed. See +[ADR-0011](decisions/0011-user-scoped-tenant-discovery.md). + ### `POST /api/mgmt/templates/{templateId}/tenants` Provision a tenant. The slug becomes the subdomain (substituted into the template's @@ -190,6 +243,8 @@ transaction and the first deploy is queued. Poll the tenant's status (below) to - Slugs must start with a letter or digit and contain only lowercase letters, digits, and hyphens; anything else is `400`. +- **`accessible` is a reserved slug** and is refused with `400` — it names the user-filtered listing + endpoint above, so a tenant called that could never be addressed at `…/tenants/accessible`. - Env keys must be valid variable names — `^[A-Za-z_][A-Za-z0-9_]*$` — or the request is `400`. Because `env` is a JSON *object*, a key repeated in the same body is not an error: the JSON parser keeps the last occurrence, so the request succeeds with that value. @@ -317,6 +372,11 @@ curl -sS "${AUTH[@]}" "$WATCHTOWER_URL/api/mgmt/templates" # All customers of template 3 curl -sS "${AUTH[@]}" "$WATCHTOWER_URL/api/mgmt/templates/3/tenants" +# ...and only the ones THIS visitor may open ($VISITOR_JWT is the X-Watchtower-Jwt of the +# request your UI is serving, not a stored value) +curl -sS "${AUTH[@]}" -H "X-Watchtower-Jwt: $VISITOR_JWT" \ + "$WATCHTOWER_URL/api/mgmt/templates/3/tenants/accessible" + # Sign-up: provision "customer4" with two per-tenant overrides curl -sS "${AUTH[@]}" -X POST -H 'Content-Type: application/json' \ "$WATCHTOWER_URL/api/mgmt/templates/3/tenants" \ diff --git a/src/Watchtower.Api.Tests/MgmtApiTests.cs b/src/Watchtower.Api.Tests/MgmtApiTests.cs index 183a627..55170de 100644 --- a/src/Watchtower.Api.Tests/MgmtApiTests.cs +++ b/src/Watchtower.Api.Tests/MgmtApiTests.cs @@ -271,6 +271,32 @@ public async Task CreateTenant_RejectsABadSlugOrEnvironmentNameWith400(string bo s => s.TemplateId == billing, TestContext.Current.CancellationToken))); } + /// + /// accessible is a literal segment of this surface's own tenant routes and outranks + /// {slug} when routing, so a tenant of that name would be created and then be unreachable on + /// GET …/tenants/{slug}. The shared provisioning path refuses it, which is what makes the + /// collision impossible rather than merely documented. + /// + [Theory] + [InlineData("accessible")] + [InlineData("Accessible")] + public async Task CreateTenant_RefusesTheReservedSlugWith400(string slug) { + using var factory = new WatchtowerApiFactory(); + var (stackId, token) = await factory.AddCallerStackAsync("vendor-console"); + var billing = await factory.AddTemplateAsync("billing"); + await factory.GrantManagementAsync(stackId, billing); + + using var client = factory.CreateApiClient(); + var response = await SendAsync(client, HttpMethod.Post, $"{Templates}/{billing}/tenants", token, + $$"""{"slug":"{{slug}}"}"""); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("reserved", await BodyAsync(response), StringComparison.Ordinal); + Assert.False(await factory.ReadAsync(db => db.Stacks.AnyAsync( + s => s.TemplateId == billing, TestContext.Current.CancellationToken))); + Assert.Empty(factory.DeployQueue.Calls); + } + [Fact] public async Task CreateTenant_RejectsAMalformedBodyWith400() { using var factory = new WatchtowerApiFactory(); diff --git a/src/Watchtower.Api.Tests/MgmtTestEstate.cs b/src/Watchtower.Api.Tests/MgmtTestEstate.cs index 41f6007..83e7420 100644 --- a/src/Watchtower.Api.Tests/MgmtTestEstate.cs +++ b/src/Watchtower.Api.Tests/MgmtTestEstate.cs @@ -44,27 +44,50 @@ await factory.WithScopeAsync(async sp => { } /// Adds a stack with an App API token and returns its id and that token. + /// + /// gives the caller a primary route of its own. The tenant-discovery + /// endpoints need one: a stack's route domains are the audiences it may present an assertion for. + /// public static async Task<(int StackId, string Token)> AddCallerStackAsync( - this WatchtowerApiFactory factory, string name, bool appApiEnabled = true) { + this WatchtowerApiFactory factory, string name, bool appApiEnabled = true, string? domain = null) { var stackId = 0; var token = string.Empty; await factory.WithScopeAsync(async sp => { var db = sp.GetRequiredService(); + var ct = TestContext.Current.CancellationToken; var stack = NewStack(name); stack.AppApiToken = AppApiTokens.Generate(); stack.AppApiEnabled = appApiEnabled; db.Stacks.Add(stack); - await db.SaveChangesAsync(TestContext.Current.CancellationToken); + await db.SaveChangesAsync(ct); stackId = stack.Id; token = stack.AppApiToken; + + if (domain is null) return; + db.Routes.Add(new Route { + StackId = stack.Id, + Domain = domain, + ServiceName = "web", + ContainerPort = 8080, + IsPrimary = true, + Kind = DomainKind.Managed, + CreatedAt = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(ct); }); return (stackId, token); } /// Adds a tenant stack of with its primary route. + /// + /// and exist for the tenant-switcher tests: + /// the route's mode is what decides whether a visitor sees the tenant at all, and a tenant with no + /// route is the "nothing to switch to" case. + /// public static async Task AddTenantAsync( this WatchtowerApiFactory factory, int templateId, string slug, - string? stackNamePrefix = null, string? domain = null, DeployStatus? lastDeployStatus = null) { + string? stackNamePrefix = null, string? domain = null, DeployStatus? lastDeployStatus = null, + AccessMode accessMode = AccessMode.Public, bool withRoute = true) { var stackId = 0; await factory.WithScopeAsync(async sp => { var db = sp.GetRequiredService(); @@ -78,15 +101,17 @@ await factory.WithScopeAsync(async sp => { db.Stacks.Add(stack); await db.SaveChangesAsync(ct); - db.Routes.Add(new Route { - StackId = stack.Id, - Domain = domain ?? $"{slug}.example.com", - ServiceName = "web", - ContainerPort = 8080, - IsPrimary = true, - Kind = DomainKind.Managed, - CreatedAt = DateTimeOffset.UtcNow, - }); + if (withRoute) + db.Routes.Add(new Route { + StackId = stack.Id, + Domain = domain ?? $"{slug}.example.com", + ServiceName = "web", + ContainerPort = 8080, + IsPrimary = true, + Kind = DomainKind.Managed, + AccessMode = accessMode, + CreatedAt = DateTimeOffset.UtcNow, + }); db.StackEnvVars.Add(new StackEnvVar { StackId = stack.Id, Key = "SECRET", Value = "hunter2" }); await db.SaveChangesAsync(ct); stackId = stack.Id; @@ -94,6 +119,36 @@ await factory.WithScopeAsync(async sp => { return stackId; } + /// The App API token a seeded stack authenticates with. + public static Task AppApiTokenAsync(this WatchtowerApiFactory factory, int stackId) => + factory.ReadAsync(db => db.Stacks.AsNoTracking() + .Where(s => s.Id == stackId) + .Select(s => s.AppApiToken!) + .SingleAsync(TestContext.Current.CancellationToken)); + + /// The id of a stack's primary route — what a points at. + public static Task PrimaryRouteIdAsync(this WatchtowerApiFactory factory, int stackId) => + factory.ReadAsync(db => db.Routes.AsNoTracking() + .Where(r => r.StackId == stackId && r.IsPrimary) + .Select(r => r.Id) + .SingleAsync(TestContext.Current.CancellationToken)); + + /// + /// Writes a raw access_mode value straight into the routes table, bypassing the enum. + /// + /// + /// The only way to model what a downgrade leaves behind: a newer build persists a mode this one has no + /// name for. A numeric value is what EF itself would have written, and it reads back as an undefined + /// enum value rather than throwing — which is precisely the input the policy has to fail closed on. + /// + public static Task SetRawAccessModeAsync(this WatchtowerApiFactory factory, int routeId, string raw) => + factory.WithScopeAsync(async sp => { + var db = sp.GetRequiredService(); + await db.Database.ExecuteSqlAsync( + $"UPDATE routes SET access_mode = {raw} WHERE id = {routeId}", + TestContext.Current.CancellationToken); + }); + /// Lets manage 's tenants. public static Task GrantManagementAsync( this WatchtowerApiFactory factory, int stackId, int templateId, bool allowDelete = false) => diff --git a/src/Watchtower.Api.Tests/TenantSwitcherTests.cs b/src/Watchtower.Api.Tests/TenantSwitcherTests.cs new file mode 100644 index 0000000..bd04407 --- /dev/null +++ b/src/Watchtower.Api.Tests/TenantSwitcherTests.cs @@ -0,0 +1,413 @@ +using System.Net; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Watchtower.Application.Entities; +using Watchtower.Application.Persistence; +using Watchtower.Application.Services; +using Xunit; + +namespace Watchtower.Api.Tests; + +/// +/// Covers the two user-scoped tenant-discovery endpoints against the real host pipeline: +/// GET /api/app/tenants/accessible (a tenant stack asking which siblings its visitor may switch to) +/// and GET /api/mgmt/templates/{id}/tenants/accessible (a vendor's management UI asking the same of +/// a template it has been granted). +/// +/// +/// +/// These are the only endpoints where a stack learns anything about another stack, so the tests here are +/// mostly about what does not come back. Three properties carry the design and each is pinned +/// below: the user is named by a Watchtower-signed assertion rather than by anything in the request; that +/// assertion is only accepted when its aud is one of the calling stack's own domains, so an +/// app can only ask about somebody actually visiting it; and every way of failing that check produces one +/// indistinguishable 401. +/// +/// +/// Expiry is not exercised here. The host's clock is the real one — the App API factory does not substitute +/// a TimeProvider — and, more to the point, every assertion failure funnels through a single branch, +/// so an expired token could not produce a different answer than the wrong-audience one already asserted. +/// The expiry check itself is covered where it lives, in AuthTokenSignerTests. +/// +/// +public sealed class TenantSwitcherTests { + private const string AuthHost = "watchtower.example.invalid"; + private const string AppPath = "/api/app/tenants/accessible"; + private const string CallerDomain = "acme.example.com"; + private const string ConsoleDomain = "console.example.com"; + + private static WatchtowerApiFactory AuthOn() => + new(("Watchtower:Auth:Enabled", "true"), ("Watchtower:Auth:Host", AuthHost)); + + // ── GET /api/app/tenants/accessible ───────────────────────────────────────── + + [Fact] + public async Task App_ListsTheSiblingsTheVisitorMayEnter_AndFlagsTheCallersOwnRow() { + using var factory = AuthOn(); + var estate = await SeedEstateAsync(factory); + using var client = factory.CreateApiClient(); + + var response = await SendAsync(client, AppPath, estate.CallerToken, + await AssertionAsync(factory, estate.UserId, CallerDomain)); + var raw = await BodyAsync(response); + var tenants = JsonDocument.Parse(raw).RootElement.GetProperty("tenants").EnumerateArray().ToList(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + // Public and Authenticated siblings, plus the Restricted one this user holds a grant for. The + // ungranted Restricted tenant, the tenant whose mode this build cannot read, the tenant with no + // route to switch to, and the other template's tenant are all absent. + // …by slug ascending, not in the order the rows were written: `abacus` was seeded last. + Assert.Equal(["abacus", "acme", "globex", "vip"], + tenants.Select(t => t.GetProperty("slug").GetString())); + Assert.Equal("acme.example.com", tenants[1].GetProperty("domain").GetString()); + // `current` is true for exactly the stack answering the request. + Assert.Equal([false, true, false, false], + tenants.Select(t => t.GetProperty("current").GetBoolean())); + + // Nothing operational rides along: no stack ids, no deploy state, no environment values. + Assert.DoesNotContain("stackId", raw, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("lastDeploy", raw, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("hunter2", raw, StringComparison.Ordinal); + Assert.DoesNotContain("wtapp_", raw, StringComparison.Ordinal); + Assert.DoesNotContain("secret", raw, StringComparison.Ordinal); + Assert.DoesNotContain("elsewhere", raw, StringComparison.Ordinal); + } + + /// + /// The stack credential is still the first gate: a request carrying a perfectly good user assertion but + /// no App API token is refused as an unauthenticated stack, and never reaches the assertion at all. + /// + [Fact] + public async Task App_WithoutTheStackToken_Is401_WhateverTheAssertionSays() { + using var factory = AuthOn(); + var estate = await SeedEstateAsync(factory); + using var client = factory.CreateApiClient(); + + var response = await SendAsync(client, AppPath, token: null, + await AssertionAsync(factory, estate.UserId, CallerDomain)); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Contains("Missing or invalid App API token.", await BodyAsync(response), StringComparison.Ordinal); + } + + /// + /// Every way of failing the assertion check answers identically. Telling "no token" from "wrong + /// audience" from "that account is disabled" is exactly the signal an enumeration attempt wants. + /// + [Fact] + public async Task App_EveryAssertionFailure_IsTheSame401() { + using var factory = AuthOn(); + var estate = await SeedEstateAsync(factory); + var disabled = await factory.AddUserAsync("mallory", disabled: true); + using var client = factory.CreateApiClient(); + + var minted = await AssertionAsync(factory, estate.UserId, CallerDomain); + var bodies = new List(); + foreach (var assertion in new[] { + // Absent entirely. + null, + // Not a token at all. + "not-a-jwt", + // Cryptographically sound, but minted for a different app — the anti-enumeration case. + await AssertionAsync(factory, estate.UserId, "somewhere.else.example.com"), + // Ours, but tampered with. + minted[..^1] + (minted[^1] == 'A' ? 'B' : 'A'), + // Ours and current, for an account that is disabled. + await AssertionAsync(factory, disabled, CallerDomain), + }) { + var response = await SendAsync(client, AppPath, estate.CallerToken, assertion); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + // Not the session challenge: this surface answers with its own body, never WWW-Authenticate. + Assert.Empty(response.Headers.WwwAuthenticate); + bodies.Add(await BodyAsync(response)); + } + + Assert.Contains("Missing or invalid user assertion.", bodies[0], StringComparison.Ordinal); + Assert.All(bodies, body => Assert.Equal(bodies[0], body)); + } + + /// + /// An assertion that was valid a moment ago stops being accepted the moment the account is disabled — + /// the row is reloaded per request rather than trusted from mint time. + /// + [Fact] + public async Task App_ForAnAccountDisabledAfterTheAssertionWasMinted_Is401() { + using var factory = AuthOn(); + var estate = await SeedEstateAsync(factory); + using var client = factory.CreateApiClient(); + var assertion = await AssertionAsync(factory, estate.UserId, CallerDomain); + + Assert.Equal(HttpStatusCode.OK, + (await SendAsync(client, AppPath, estate.CallerToken, assertion)).StatusCode); + + await factory.WithScopeAsync(async sp => { + var db = sp.GetRequiredService(); + var user = await db.Users.FindAsync([estate.UserId], Ct); + user!.Disabled = true; + await db.SaveChangesAsync(Ct); + }); + + Assert.Equal(HttpStatusCode.Unauthorized, + (await SendAsync(client, AppPath, estate.CallerToken, assertion)).StatusCode); + } + + /// + /// A standalone stack has no siblings, and it already knows that about itself — so it is told plainly, + /// before and regardless of any assertion. + /// + [Fact] + public async Task App_FromAStackThatIsNotATenant_Is404() { + using var factory = AuthOn(); + var estate = await SeedEstateAsync(factory); + var (_, token) = await factory.AddCallerStackAsync("standalone", domain: "standalone.example.com"); + using var client = factory.CreateApiClient(); + + var withAssertion = await SendAsync(client, AppPath, token, + await AssertionAsync(factory, estate.UserId, "standalone.example.com")); + var without = await SendAsync(client, AppPath, token, assertion: null); + + Assert.Equal(HttpStatusCode.NotFound, withAssertion.StatusCode); + Assert.Contains("This stack is not a tenant of a template.", + await BodyAsync(withAssertion), StringComparison.Ordinal); + Assert.Equal(HttpStatusCode.NotFound, without.StatusCode); + } + + /// + /// A caller Watchtower serves no domain for has no audience to bind an assertion to, and "no audience" + /// must never be read as "any audience" — an otherwise perfect assertion is refused on both surfaces. + /// + /// + /// This is the surface-level guard on the db.RoutesTryValidate wiring: were the + /// audience set ever to fall back to every domain Watchtower knows (or to an empty set read as + /// unconstrained), these two calls would start succeeding, and any stack could ask about any visitor. + /// + [Fact] + public async Task ACallerWithNoRouteDomains_Is401_HoweverGoodTheAssertionIs() { + using var factory = AuthOn(); + var estate = await SeedEstateAsync(factory); + // A tenant of the same template, provisioned but not yet routed. + var routeless = await factory.AddTenantAsync(estate.TemplateId, "hermit", withRoute: false); + // …and a granted management stack that has no domain of its own either. + var (headlessId, headlessToken) = await factory.AddCallerStackAsync("headless-console"); + await factory.GrantManagementAsync(headlessId, estate.TemplateId); + using var client = factory.CreateApiClient(); + + // Current, correctly signed, for a live account, and minted for a domain this estate really serves + // — just not one of *these* callers'. + var assertion = await AssertionAsync(factory, estate.UserId, CallerDomain); + var app = await SendAsync(client, AppPath, await factory.AppApiTokenAsync(routeless), assertion); + var mgmt = await SendAsync(client, MgmtPath(estate.TemplateId), headlessToken, assertion); + + Assert.Equal(HttpStatusCode.Unauthorized, app.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, mgmt.StatusCode); + // The same uniform message, not a distinct "you have no domains" answer. + Assert.Contains("Missing or invalid user assertion.", await BodyAsync(app), StringComparison.Ordinal); + Assert.Equal(await BodyAsync(app), await BodyAsync(mgmt)); + } + + // ── GET /api/mgmt/templates/{id}/tenants/accessible ───────────────────────── + + [Fact] + public async Task Mgmt_ListsTheAccessibleTenants_WithoutACurrentFlag() { + using var factory = AuthOn(); + var estate = await SeedEstateAsync(factory); + using var client = factory.CreateApiClient(); + + var response = await SendAsync(client, MgmtPath(estate.TemplateId), estate.ConsoleToken, + await AssertionAsync(factory, estate.UserId, ConsoleDomain)); + var raw = await BodyAsync(response); + var tenants = JsonDocument.Parse(raw).RootElement.GetProperty("tenants").EnumerateArray().ToList(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + // Same slug ordering as the App API's answer, and likewise not the insertion order. + Assert.Equal(["abacus", "acme", "globex", "vip"], + tenants.Select(t => t.GetProperty("slug").GetString())); + Assert.Equal("vip.example.com", tenants[3].GetProperty("domain").GetString()); + // A management stack is not one of the tenants it lists, so the field is absent rather than false. + Assert.All(tenants, t => Assert.False(t.TryGetProperty("current", out _))); + Assert.DoesNotContain("stackId", raw, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("hunter2", raw, StringComparison.Ordinal); + } + + /// + /// The grant gate keeps its place at the front of the chain: an ungranted template answers the + /// surface's uniform 404 before the assertion is looked at, so this endpoint cannot become the oracle + /// for template ids the rest of the management API refuses to be. + /// + [Fact] + public async Task Mgmt_AnUngrantedTemplate_Is404_AheadOfAnyAssertionCheck() { + using var factory = AuthOn(); + var estate = await SeedEstateAsync(factory); + var ungranted = await factory.AddTemplateAsync("unrelated", "{tenant}.unrelated.example.com"); + using var client = factory.CreateApiClient(); + var assertion = await AssertionAsync(factory, estate.UserId, ConsoleDomain); + + var existing = await SendAsync(client, MgmtPath(ungranted), estate.ConsoleToken, assertion); + var missing = await SendAsync(client, MgmtPath(424242), estate.ConsoleToken, assertion); + // Same answer with no assertion at all: the 404 does not depend on getting that part right. + var anonymousUser = await SendAsync(client, MgmtPath(ungranted), estate.ConsoleToken, assertion: null); + + Assert.Equal(HttpStatusCode.NotFound, existing.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, anonymousUser.StatusCode); + Assert.Equal(await BodyAsync(existing), await BodyAsync(missing)); + Assert.Equal(await BodyAsync(existing), await BodyAsync(anonymousUser)); + Assert.Contains("Template not found.", await BodyAsync(existing), StringComparison.Ordinal); + } + + /// + /// The audience must be one of the management stack's own domains. An assertion minted for one + /// of the tenants — which a management UI could plausibly get hold of — proves nothing about who is + /// standing in front of the console, and is refused. + /// + [Fact] + public async Task Mgmt_WithAnAssertionMintedForATenantRatherThanTheConsole_Is401() { + using var factory = AuthOn(); + var estate = await SeedEstateAsync(factory); + using var client = factory.CreateApiClient(); + + var response = await SendAsync(client, MgmtPath(estate.TemplateId), estate.ConsoleToken, + await AssertionAsync(factory, estate.UserId, CallerDomain)); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Contains("Missing or invalid user assertion.", await BodyAsync(response), StringComparison.Ordinal); + } + + [Fact] + public async Task Mgmt_WithoutAnAssertion_Is401() { + using var factory = AuthOn(); + var estate = await SeedEstateAsync(factory); + using var client = factory.CreateApiClient(); + + var response = await SendAsync(client, MgmtPath(estate.TemplateId), estate.ConsoleToken, assertion: null); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Contains("Missing or invalid user assertion.", await BodyAsync(response), StringComparison.Ordinal); + } + + /// + /// The literal accessible segment outranks {slug} in routing, so the two endpoints do not + /// collide — the unfiltered tenant listing is untouched and still answers everything. + /// + [Fact] + public async Task Mgmt_TheUnfilteredListingIsUnchanged_AndStillShowsWhatTheUserCannotEnter() { + using var factory = AuthOn(); + var estate = await SeedEstateAsync(factory); + using var client = factory.CreateApiClient(); + + var response = await SendAsync( + client, $"/api/mgmt/templates/{estate.TemplateId}/tenants", estate.ConsoleToken, assertion: null); + var tenants = JsonDocument.Parse(await BodyAsync(response)) + .RootElement.GetProperty("tenants").EnumerateArray().ToList(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("secret", tenants.Select(t => t.GetProperty("slug").GetString())); + } + + // ── Auth off ──────────────────────────────────────────────────────────────── + + /// + /// With central auth switched off no assertion can exist, so asking for one would be nonsense: both + /// endpoints are 404, exactly as the forward-auth surface is. Mapped rather than left out, or the SPA + /// fallback would answer index.html with a 200. + /// + [Fact] + public async Task WithAuthDisabled_BothEndpointsAre404_WhileTheRestOfTheSurfaceStillAnswers() { + using var factory = new WatchtowerApiFactory(); + var estate = await SeedEstateAsync(factory); + using var client = factory.CreateApiClient(); + + var app = await SendAsync(client, AppPath, estate.CallerToken, assertion: null); + var mgmt = await SendAsync(client, MgmtPath(estate.TemplateId), estate.ConsoleToken, assertion: null); + var unfiltered = await SendAsync( + client, $"/api/mgmt/templates/{estate.TemplateId}/tenants", estate.ConsoleToken, assertion: null); + + Assert.Equal(HttpStatusCode.NotFound, app.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, mgmt.StatusCode); + // Not a blanket closure of the surface — everything that does not need an identity still works. + Assert.Equal(HttpStatusCode.OK, unfiltered.StatusCode); + } + + // ── Estate ────────────────────────────────────────────────────────────────── + + /// The seeded estate: one template, its tenants in every access mode, and the two callers. + /// The template both endpoints are asked about. + /// App API token of the acme tenant, which calls the App API endpoint. + /// App API token of the granted management stack. + /// The visiting account every assertion is minted for. + private sealed record Estate(int TemplateId, string CallerToken, string ConsoleToken, int UserId); + + /// + /// One template with a tenant per interesting access mode, a second template that must never show up, + /// the calling tenant, and a management stack granted the first template. + /// + private static async Task SeedEstateAsync(WatchtowerApiFactory factory) { + var billing = await factory.AddTemplateAsync("billing"); + var portal = await factory.AddTemplateAsync("portal", "{tenant}.portal.example.com"); + var userId = await factory.AddUserAsync("alice", "alice@example.invalid"); + + // The caller itself: a Public tenant, so it appears in its own answer with current = true. + var caller = await factory.AddTenantAsync(billing, "acme", domain: CallerDomain); + await factory.AddTenantAsync(billing, "globex", accessMode: AccessMode.Authenticated); + // Restricted, and this user holds the grant. + var vip = await factory.AddTenantAsync(billing, "vip", accessMode: AccessMode.Restricted); + await factory.GrantAsync(await factory.PrimaryRouteIdAsync(vip), userId); + // Restricted with no grant for this user. + await factory.AddTenantAsync(billing, "secret", accessMode: AccessMode.Restricted); + // A mode written by a newer build: unreadable here, and therefore not accessible. + var future = await factory.AddTenantAsync(billing, "future", accessMode: AccessMode.Restricted); + await factory.SetRawAccessModeAsync(await factory.PrimaryRouteIdAsync(future), "99"); + await factory.GrantAsync(await factory.PrimaryRouteIdAsync(future), userId); + // Provisioned but routeless: there is no domain to switch to. + await factory.AddTenantAsync(billing, "roadless", withRoute: false); + // Another template's tenant, which this template's visitor must never be offered. + await factory.AddTenantAsync(portal, "elsewhere", "portal", "elsewhere.portal.example.com"); + // Seeded last and sorts first ("ab" < "ac"), so insertion order and slug order disagree and the + // "by slug ascending" assertions have something to bite on. + // + // With one honest limit worth knowing before trusting them: deleting the sort in + // TenantDiscoveryService alone does *not* fail them, because SQLite answers the tenants query from + // the unique (template_id, tenant_slug) index and hands back slug order anyway. What this fixture + // does catch is the realistic drift — the tenants query acquiring an order of its own, e.g. the + // `OrderByDescending(s => s.Id)` ("newest first") that the neighbouring ListTenantsAsync uses. + // Verified by mutation: that change without the sort fails both listing tests, and with the sort + // keeps them green. So the sort is the guarantee, and this is the assertion that holds it. + await factory.AddTenantAsync(billing, "abacus", accessMode: AccessMode.Authenticated); + + var (consoleId, consoleToken) = await factory.AddCallerStackAsync("vendor-console", domain: ConsoleDomain); + await factory.GrantManagementAsync(consoleId, billing); + + return new Estate(billing, await factory.AppApiTokenAsync(caller), consoleToken, userId); + } + + // ── Helpers ───────────────────────────────────────────────────────────────── + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static string MgmtPath(int templateId) => $"/api/mgmt/templates/{templateId}/tenants/accessible"; + + private static Task SendAsync( + HttpClient client, string url, string? token, string? assertion) { + var request = new HttpRequestMessage(HttpMethod.Get, url); + if (token is not null) request.Headers.Add("Authorization", $"Bearer {token}"); + if (assertion is not null) request.Headers.TryAddWithoutValidation(RouteAccessPolicy.JwtHeaderName, assertion); + return client.SendAsync(request, Ct); + } + + /// Mints the assertion a stack serving would have been forwarded. + private static async Task AssertionAsync( + WatchtowerApiFactory factory, int userId, string audience) { + var token = string.Empty; + await factory.WithScopeAsync(async sp => { + var db = sp.GetRequiredService(); + var signer = sp.GetRequiredService(); + var user = await db.Users.SingleAsync(u => u.Id == userId, Ct); + token = signer.Mint(user, audience); + }); + return token; + } + + private static Task BodyAsync(HttpResponseMessage response) => + response.Content.ReadAsStringAsync(Ct); +} diff --git a/src/Watchtower.Api/Endpoints/AppApiEndpoints.cs b/src/Watchtower.Api/Endpoints/AppApiEndpoints.cs index 2c20828..cd5772a 100644 --- a/src/Watchtower.Api/Endpoints/AppApiEndpoints.cs +++ b/src/Watchtower.Api/Endpoints/AppApiEndpoints.cs @@ -21,20 +21,37 @@ namespace Watchtower.Api.Endpoints; /// Responses deliberately exclude deploy output, environment variable values and credentials. /// /// +/// One endpoint is not about the caller's own state: /tenants/accessible names sibling tenants. +/// It is not an exception to the rule above so much as a different question — it answers on behalf of a +/// visitor the caller proves with their forwarded identity assertion, about apps that visitor could reach +/// by typing the domain, and it carries nothing operational about those stacks. +/// +/// /// Per ADR-0003 these are plain minimal-API routes rather than JSON-RPC handlers: they are externally /// facing with their own auth semantics, and one of them is a stream. /// /// public static class AppApiEndpoints { + /// + /// The user-scoped tenant switcher feed. Not "the caller's own state" like the rest of this surface, + /// which is why it carries a second credential — see . + /// + private const string AccessibleTenantsPath = "/api/app/tenants/accessible"; + /// Maps every /api/app/* route onto the application. /// The web application to map onto. + /// + /// Whether central authentication is configured. With it off no identity assertion exists, so the + /// tenant-switcher endpoint answers 404 instead of asking for one. + /// /// The same application, for chaining. - public static WebApplication MapAppApiEndpoints(this WebApplication app) { + public static WebApplication MapAppApiEndpoints(this WebApplication app, bool authEnabled) { MapSelf(app); MapStatus(app); MapDeployments(app); MapVersion(app); MapLogs(app); + MapAccessibleTenants(app, authEnabled); return app; } @@ -108,6 +125,65 @@ private static void MapLogs(WebApplication app) => return await SseLogStreaming.ServeLogsAsync(response, docker, containers, service, tail, follow, ct); }); + /// + /// The sibling tenants of the caller's own template that the visiting user may switch to. + /// + /// + /// + /// Two credentials, and both are load-bearing. The stack authenticates as usual with its App API token, + /// and additionally forwards the X-Watchtower-Jwt assertion from the request it is currently + /// serving. The assertion is what names the user — no endpoint here accepts a bare user id — and it is + /// only accepted when its aud is one of this stack's domains, so an app can only ask + /// about somebody actually visiting it. + /// + /// + /// A stack that is not a tenant of a template gets 404: it has nothing to switch between, and it already + /// knows its own nature, so saying so discloses nothing. Every assertion failure gets the same 401. + /// + /// + private static void MapAccessibleTenants(WebApplication app, bool authEnabled) { + // Mapped-but-404 rather than left unmapped: an unmapped /api/app/* path falls through to the SPA + // fallback and answers index.html with a 200, which is a baffling reply to an API call. Same shape + // as the forward-auth surface takes (see WatchtowerAccessEndpoints). + if (!authEnabled) { + app.MapGet(AccessibleTenantsPath, () => Results.NotFound()); + return; + } + + app.MapGet(AccessibleTenantsPath, async ( + HttpRequest request, AppApiService api, TenantDiscoveryService tenants, CancellationToken ct) => { + var (failure, caller) = await AuthenticateAsync(request, api, ct); + if (failure is not null) return failure; + + var templateId = await tenants.ResolveTenantTemplateIdAsync(caller!.StackId, ct); + if (templateId is null) + return Results.Json(new AppApiErrorDto("This stack is not a tenant of a template."), + statusCode: StatusCodes.Status404NotFound); + + var userId = await tenants.ResolveAssertionSubjectAsync( + caller.StackId, request.Headers[RouteAccessPolicy.JwtHeaderName], ct); + if (userId is null) return InvalidUserAssertion(); + + var accessible = await tenants.ListAccessibleTenantsAsync(templateId.Value, userId.Value, ct); + return Results.Json(new AppAccessibleTenantsDto(accessible + .Select(t => new AppAccessibleTenantDto(t.Slug, t.Domain, t.StackId == caller.StackId)) + .ToList())); + }); + } + + /// + /// 401 for a request whose forwarded identity assertion is missing, unverifiable, minted for another + /// app, or names an account that is gone or disabled. + /// + /// + /// Deliberately one message for all of those, shared by both public surfaces: an answer that + /// distinguished them would tell a caller which check it tripped, and "wrong audience" versus "no such + /// account" is exactly the difference an enumeration attempt is looking for. + /// + internal static IResult InvalidUserAssertion() => + Results.Json(new AppApiErrorDto("Missing or invalid user assertion."), + statusCode: StatusCodes.Status401Unauthorized); + /// 503 response used when the Docker daemon cannot be reached. /// Shared with the management API, which reads live state through the same client. internal static IResult DockerUnavailable() => @@ -150,9 +226,11 @@ internal static IResult DockerUnavailable() => [JsonSerializable(typeof(AppStatusDto))] [JsonSerializable(typeof(AppDeploymentsDto))] [JsonSerializable(typeof(AppVersionDto))] +[JsonSerializable(typeof(AppAccessibleTenantsDto))] [JsonSerializable(typeof(AppApiErrorDto))] [JsonSerializable(typeof(MgmtTemplatesDto))] [JsonSerializable(typeof(MgmtTenantsDto))] +[JsonSerializable(typeof(MgmtAccessibleTenantsDto))] [JsonSerializable(typeof(MgmtTenantCreatedDto))] [JsonSerializable(typeof(MgmtTenantStatusDto))] [JsonSerializable(typeof(MgmtDeployAcceptedDto))] diff --git a/src/Watchtower.Api/Endpoints/MgmtApiEndpoints.cs b/src/Watchtower.Api/Endpoints/MgmtApiEndpoints.cs index 409b746..7ed2a0a 100644 --- a/src/Watchtower.Api/Endpoints/MgmtApiEndpoints.cs +++ b/src/Watchtower.Api/Endpoints/MgmtApiEndpoints.cs @@ -32,12 +32,25 @@ namespace Watchtower.Api.Endpoints; /// /// public static class MgmtApiEndpoints { + /// + /// The user-scoped tenant feed. The literal accessible segment outranks the {slug} + /// parameter when routing, which would shadow a tenant of that name on the tenant-status route — so the + /// word is reserved at provisioning () and no such + /// tenant can be created. Any literal segment added here later has to join that set. + /// + private const string AccessibleTenantsPath = "/api/mgmt/templates/{templateId:int}/tenants/accessible"; + /// Maps every /api/mgmt/* route onto the application. /// The web application to map onto. + /// + /// Whether central authentication is configured. With it off no identity assertion exists, so the + /// user-filtered tenant listing answers 404 instead of asking for one. + /// /// The same application, for chaining. - public static WebApplication MapMgmtApiEndpoints(this WebApplication app) { + public static WebApplication MapMgmtApiEndpoints(this WebApplication app, bool authEnabled) { MapTemplates(app); MapTenants(app); + MapAccessibleTenants(app, authEnabled); MapCreateTenant(app); MapTenantStatus(app); MapDeploy(app); @@ -63,6 +76,48 @@ private static void MapTenants(WebApplication app) => return failure ?? Results.Json(await api.ListTenantsAsync(templateId, ct)); }); + /// + /// The managed template's tenants that the visiting user may enter — the same tenant-switcher + /// feed the App API serves, for a vendor's management UI. + /// + /// + /// + /// The grant gate runs first and unchanged: an ungranted template is the surface's uniform 404 before + /// any assertion is looked at, so this endpoint cannot become the oracle for template ids the rest of + /// the surface refuses to be. Only then is the forwarded X-Watchtower-Jwt considered, under + /// exactly the App API's rules — its aud must be one of the management stack's own + /// domains, and every failure is the same 401. + /// + /// + /// The unfiltered listing is untouched and remains the operational view; this + /// one answers "which of these may the person looking at the screen open?". + /// + /// + private static void MapAccessibleTenants(WebApplication app, bool authEnabled) { + // Mapped-but-404 with auth off, for the same reason the App API does it: an unmapped path would be + // answered by the SPA fallback with index.html and a 200. + if (!authEnabled) { + app.MapGet(AccessibleTenantsPath, () => Results.NotFound()); + return; + } + + app.MapGet(AccessibleTenantsPath, async ( + int templateId, HttpRequest request, + MgmtApiService api, TenantDiscoveryService tenants, CancellationToken ct) => { + var (failure, caller, _) = await AuthorizeAsync(request, api, templateId, ct); + if (failure is not null) return failure; + + var userId = await tenants.ResolveAssertionSubjectAsync( + caller!.StackId, request.Headers[RouteAccessPolicy.JwtHeaderName], ct); + if (userId is null) return AppApiEndpoints.InvalidUserAssertion(); + + var accessible = await tenants.ListAccessibleTenantsAsync(templateId, userId.Value, ct); + return Results.Json(new MgmtAccessibleTenantsDto(accessible + .Select(t => new MgmtAccessibleTenantDto(t.Slug, t.Domain)) + .ToList())); + }); + } + /// /// Provisions a tenant: a stack of its own, the template's env vars with the request's overrides /// merged in, a route derived from the template's domain pattern, and an initial deploy. diff --git a/src/Watchtower.Api/Endpoints/WatchtowerAccessEndpoints.cs b/src/Watchtower.Api/Endpoints/WatchtowerAccessEndpoints.cs index 1d9a256..4ff7781 100644 --- a/src/Watchtower.Api/Endpoints/WatchtowerAccessEndpoints.cs +++ b/src/Watchtower.Api/Endpoints/WatchtowerAccessEndpoints.cs @@ -346,7 +346,8 @@ private static void MapUserInfo(WebApplication app) { /// /// Authorization: Bearer <Watchtower JWT> — the standard UserInfo path, where an app /// presents the assertion it received. The signature, algorithm, expiry and issuer are all checked - /// (). + /// ( — the overload that does not + /// constrain the audience, since an app may present an assertion minted for its own domain). /// /// /// the __wt_access cookie — the browser same-origin path, resolved to its session by hash. diff --git a/src/Watchtower.Api/Endpoints/WatchtowerHttpEndpoints.cs b/src/Watchtower.Api/Endpoints/WatchtowerHttpEndpoints.cs index 3fa2f09..8ebc349 100644 --- a/src/Watchtower.Api/Endpoints/WatchtowerHttpEndpoints.cs +++ b/src/Watchtower.Api/Endpoints/WatchtowerHttpEndpoints.cs @@ -30,11 +30,14 @@ public static WebApplication MapWatchtowerHttpEndpoints(this WebApplication app, Protect(MapDeployOutputStream(app), authEnabled); Protect(MapContainerLogStream(app), authEnabled); MapProxyAsk(app); - // Public, token-authenticated surface for deployed applications (see AppApiEndpoints). - app.MapAppApiEndpoints(); + // Public, token-authenticated surface for deployed applications (see AppApiEndpoints). The flag is + // passed down for its one identity-dependent endpoint: the tenant switcher needs a forwarded + // assertion, which does not exist with central auth off, so that route answers 404 there. + app.MapAppApiEndpoints(authEnabled); // Public, token-authenticated surface for a stack that manages a template's tenants. Same - // credential as the App API, plus an operator-managed grant (see MgmtApiEndpoints). - app.MapMgmtApiEndpoints(); + // credential as the App API, plus an operator-managed grant (see MgmtApiEndpoints), and the same + // identity-dependent tenant listing behind the same flag. + app.MapMgmtApiEndpoints(authEnabled); app.MapGet("/health", () => Results.Ok("healthy")); return app; } diff --git a/src/Watchtower.Application.Tests/AuthTokenSignerTests.cs b/src/Watchtower.Application.Tests/AuthTokenSignerTests.cs index 1b91585..85bf25b 100644 --- a/src/Watchtower.Application.Tests/AuthTokenSignerTests.cs +++ b/src/Watchtower.Application.Tests/AuthTokenSignerTests.cs @@ -240,6 +240,103 @@ public void TryValidate_RejectsMalformedInput(string token) { Assert.Equal(0, userId); } + // ── TryValidate with audiences: the gate on the tenant-discovery endpoints ── + // + // Same verification as above plus the binding that makes those endpoints safe to expose: the caller + // passes the domains it is itself served on, so it can only ever present an assertion minted for one of + // them — i.e. one it was handed by a visitor who is actually there. + + [Fact] + public void TryValidateWithAudiences_AcceptsATokenMintedForOneOfTheCallersDomains() { + using var host = AuthTestHost.Start(); + var signer = host.Services.GetRequiredService(); + + var token = signer.Mint(User("alice"), AppDomain); + + // A stack serving several domains (a managed subdomain plus a customer's own, say) accepts an + // assertion minted for any of them — they are all "visiting this stack". + Assert.True(signer.TryValidate(token, ["first.example.invalid", AppDomain], out var userId)); + Assert.Equal(7, userId); + } + + [Fact] + public void TryValidateWithAudiences_RejectsATokenMintedForAnotherApp() { + using var host = AuthTestHost.Start(); + var signer = host.Services.GetRequiredService(); + + var token = signer.Mint(User("alice"), "other.example.invalid"); + + // The anti-enumeration property: an assertion a different app received cannot be replayed here to + // ask what its bearer may reach. + Assert.False(signer.TryValidate(token, [AppDomain], out var userId)); + Assert.Equal(0, userId); + } + + [Fact] + public void TryValidateWithAudiences_MatchesHostNamesCaseInsensitively() { + using var host = AuthTestHost.Start(); + var signer = host.Services.GetRequiredService(); + + var token = signer.Mint(User("alice"), "App.Example.INVALID"); + + // A host name is not a case-sensitive string, and the route row's casing is an operator's typing — + // it must not decide whether a visitor's own assertion is accepted. + Assert.True(signer.TryValidate(token, [AppDomain], out _)); + } + + [Fact] + public void TryValidateWithAudiences_RejectsWhenTheCallerHasNoDomains() { + using var host = AuthTestHost.Start(); + var signer = host.Services.GetRequiredService(); + + var token = signer.Mint(User("alice"), AppDomain); + + // "Nothing to bind to" is not "bind to anything": a stack Watchtower serves no domain for could not + // have been forwarded an assertion in the first place. + Assert.False(signer.TryValidate(token, [], out _)); + } + + [Fact] + public void TryValidateWithAudiences_StillEnforcesEveryOtherCheck() { + using var host = AuthTestHost.Start(); + var signer = host.Services.GetRequiredService(); + var token = signer.Mint(User("alice"), AppDomain); + Assert.True(signer.TryValidate(token, [AppDomain], out _)); + + // Expiry: the audience being right does not make a stale assertion current. + host.Time.Advance(TimeSpan.FromMinutes(30)); + Assert.False(signer.TryValidate(token, [AppDomain], out _)); + + // Tampering, on a token that is otherwise within its window. + var fresh = signer.Mint(User("alice"), AppDomain); + Assert.False(signer.TryValidate(fresh[..^1] + (fresh[^1] == 'A' ? 'B' : 'A'), [AppDomain], out _)); + + // And the algorithm pin — this one carries the *correct* audience, so its rejection can only be the + // pin doing its job rather than the audience check masking it. + using var secret = new HMACSHA256(RandomNumberGenerator.GetBytes(32)); + var hs256 = new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor { + Issuer = AuthTokenSigner.DefaultIssuer, + Audience = AppDomain, + Claims = new Dictionary { ["sub"] = "7" }, + Expires = host.Time.Now.AddMinutes(5).UtcDateTime, + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(secret.Key), SecurityAlgorithms.HmacSha256), + }); + Assert.False(signer.TryValidate(hs256, [AppDomain], out _)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-jwt")] + public void TryValidateWithAudiences_RejectsMalformedInput(string token) { + using var host = AuthTestHost.Start(); + var signer = host.Services.GetRequiredService(); + + Assert.False(signer.TryValidate(token, [AppDomain], out var userId)); + Assert.Equal(0, userId); + } + /// /// Validates the way a protected application would: keys from the published JWKS, pinned algorithm, /// checked issuer and audience. stands in for the verifier's clock, since the diff --git a/src/Watchtower.Application.Tests/RouteAccessPolicyGrantsTests.cs b/src/Watchtower.Application.Tests/RouteAccessPolicyGrantsTests.cs new file mode 100644 index 0000000..fa73b21 --- /dev/null +++ b/src/Watchtower.Application.Tests/RouteAccessPolicyGrantsTests.cs @@ -0,0 +1,171 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Watchtower.Application.Entities; +using Watchtower.Application.Persistence; +using Watchtower.Application.Services; +using Xunit; + +namespace Watchtower.Application.Tests; + +/// +/// Covers the grant-backed half of : IsAuthorizedAsync for one route +/// and AccessibleRouteIdsAsync for a whole set of them. The set form is what the tenant-discovery +/// endpoints answer with, so what matters here is that it says exactly what asking route by route would — +/// and that both stay fail-closed on a mode this build does not know. +/// +public sealed class RouteAccessPolicyGrantsTests { + [Fact] + public async Task AccessibleRouteIds_AllowsPublicAndAuthenticated_AndRestrictedOnlyWithAGrant() { + using var host = AuthTestHost.Start(); + var ct = TestContext.Current.CancellationToken; + var userId = await AddUserAsync(host, "alice"); + + var open = await AddRouteAsync(host, "open.example.invalid", AccessMode.Public); + var signedIn = await AddRouteAsync(host, "internal.example.invalid", AccessMode.Authenticated); + var granted = await AddRouteAsync(host, "granted.example.invalid", AccessMode.Restricted); + var ungranted = await AddRouteAsync(host, "closed.example.invalid", AccessMode.Restricted); + await GrantAsync(host, granted.Id, userId); + + await using var scope = host.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var accessible = await RouteAccessPolicy.AccessibleRouteIdsAsync( + db, [open, signedIn, granted, ungranted], userId, ct); + + Assert.Equal([open.Id, signedIn.Id, granted.Id], accessible.Order()); + } + + /// + /// The anti-drift assertion. The two entry points are separate methods running separate queries, and a + /// disagreement between them would not merely be a wrong answer somewhere — it would be one surface + /// showing a user an app another surface refuses them. So the set is compared against the per-route + /// verdict, route by route. + /// + [Fact] + public async Task AccessibleRouteIds_AgreesWithIsAuthorizedAsync_ForEveryRoute() { + using var host = AuthTestHost.Start(); + var ct = TestContext.Current.CancellationToken; + var userId = await AddUserAsync(host, "alice"); + + var routes = new List { + await AddRouteAsync(host, "open.example.invalid", AccessMode.Public), + await AddRouteAsync(host, "internal.example.invalid", AccessMode.Authenticated), + await AddRouteAsync(host, "granted.example.invalid", AccessMode.Restricted), + await AddRouteAsync(host, "closed.example.invalid", AccessMode.Restricted), + }; + await GrantAsync(host, routes[2].Id, userId); + + await using var scope = host.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var accessible = await RouteAccessPolicy.AccessibleRouteIdsAsync(db, routes, userId, ct); + + foreach (var route in routes) + Assert.Equal( + await RouteAccessPolicy.IsAuthorizedAsync(db, route, userId, ct), + accessible.Contains(route.Id)); + } + + /// + /// A mode from a future build, carried on a route the user does hold a grant for: were an + /// unrecognised mode ever read as "restricted" rather than refused outright, that grant would let it + /// through. Both entry points must answer no. + /// + [Fact] + public async Task AnUnknownAccessMode_FailsClosed_EvenWithAGrantOnThatRoute() { + using var host = AuthTestHost.Start(); + var ct = TestContext.Current.CancellationToken; + var userId = await AddUserAsync(host, "alice"); + + var stored = await AddRouteAsync(host, "granted.example.invalid", AccessMode.Restricted); + await GrantAsync(host, stored.Id, userId); + var future = new Route { + Id = stored.Id, + StackId = stored.StackId, + Domain = stored.Domain, + ServiceName = "web", + ContainerPort = 8080, + AccessMode = (AccessMode)99, + }; + + await using var scope = host.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // The grant is genuinely there — the stored route with the same id is accessible. + Assert.True(await RouteAccessPolicy.IsAuthorizedAsync(db, stored, userId, ct)); + Assert.False(await RouteAccessPolicy.IsAuthorizedAsync(db, future, userId, ct)); + Assert.Empty(await RouteAccessPolicy.AccessibleRouteIdsAsync(db, [future], userId, ct)); + } + + [Fact] + public async Task AccessibleRouteIds_OnlyHonoursTheGrantsOfTheUserBeingAsked() { + using var host = AuthTestHost.Start(); + var ct = TestContext.Current.CancellationToken; + var alice = await AddUserAsync(host, "alice"); + var bob = await AddUserAsync(host, "bob"); + + var route = await AddRouteAsync(host, "granted.example.invalid", AccessMode.Restricted); + await GrantAsync(host, route.Id, alice); + + await using var scope = host.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + Assert.Equal([route.Id], await RouteAccessPolicy.AccessibleRouteIdsAsync(db, [route], alice, ct)); + Assert.Empty(await RouteAccessPolicy.AccessibleRouteIdsAsync(db, [route], bob, ct)); + } + + [Fact] + public async Task AccessibleRouteIds_OfNothingIsEmpty() { + using var host = AuthTestHost.Start(); + await using var scope = host.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + Assert.Empty(await RouteAccessPolicy.AccessibleRouteIdsAsync( + db, [], userId: 1, TestContext.Current.CancellationToken)); + } + + // ── Seeding ──────────────────────────────────────────────────────────────── + + private static async Task AddRouteAsync(AuthTestHost host, string domain, AccessMode mode) { + await using var scope = host.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ct = TestContext.Current.CancellationToken; + + var name = domain.Split('.')[0]; + var stack = new Stack { + Name = name, + RepositoryUrl = $"https://example.invalid/{name}.git", + ComposeFilePath = "docker-compose.yml", + Branch = "main", + ComposeProjectName = name, + }; + db.Stacks.Add(stack); + await db.SaveChangesAsync(ct); + + var route = new Route { + StackId = stack.Id, + Domain = domain, + ServiceName = "web", + ContainerPort = 8080, + AccessMode = mode, + }; + db.Routes.Add(route); + await db.SaveChangesAsync(ct); + return route; + } + + private static async Task AddUserAsync(AuthTestHost host, string userName) { + await using var scope = host.Services.CreateAsyncScope(); + var users = scope.ServiceProvider.GetRequiredService>(); + var user = AuthTestHost.NewUser(userName); + var created = await users.CreateAsync(user, "correct-horse-battery"); + Assert.True(created.Succeeded, string.Join("; ", created.Errors.Select(e => e.Description))); + return user.Id; + } + + private static async Task GrantAsync(AuthTestHost host, int routeId, int userId) { + await using var scope = host.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.RouteAccessGrants.Add(new RouteAccessGrant { RouteId = routeId, UserId = userId }); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + } +} diff --git a/src/Watchtower.Application.Tests/TenantProvisioningServiceTests.cs b/src/Watchtower.Application.Tests/TenantProvisioningServiceTests.cs index 59d14ce..d25ad7b 100644 --- a/src/Watchtower.Application.Tests/TenantProvisioningServiceTests.cs +++ b/src/Watchtower.Application.Tests/TenantProvisioningServiceTests.cs @@ -101,6 +101,37 @@ public async Task Provision_RejectsAnUnusableSlug(string slug) { await AssertNoTenantAsync(host); } + /// + /// accessible is a literal segment of the management API's tenant routes, and a literal outranks + /// {slug} when routing — a tenant of that name would be created successfully and then be + /// unreachable on the tenant-status route. It is refused up front instead, on the normalized value, so + /// the casing the caller typed makes no difference. + /// + [Theory] + [InlineData("accessible")] + [InlineData("ACCESSIBLE")] + [InlineData(" Accessible ")] + public async Task Provision_RejectsAReservedSlug(string slug) { + using var host = AuthTestHost.Start(WithQueuedOnlyDeploys); + var templateId = await host.AddTemplateAsync("billing"); + + var result = await ProvisionAsync(host, templateId, slug); + + Assert.Equal(TenantProvisionStatus.Validation, result.Status); + Assert.Equal("Slug 'accessible' is reserved.", result.Error); + await AssertNoTenantAsync(host); + var queue = (QueuedOnlyDeployQueueService)host.Services.GetRequiredService(); + Assert.Empty(queue.Calls); + } + + /// + /// Reservation is not normalisation: a slug already in the database keeps resolving, whatever the + /// reserved set says today. Only new provisioning is refused. + /// + [Fact] + public void ReservingASlug_DoesNotChangeWhatNormalizationAccepts() => + Assert.Equal("accessible", TenancyMapping.NormalizeSlug("Accessible")); + [Fact] public async Task Provision_RejectsDuplicateEnvKeys() { using var host = AuthTestHost.Start(WithQueuedOnlyDeploys); diff --git a/src/Watchtower.Application/Services/AppApiService.cs b/src/Watchtower.Application/Services/AppApiService.cs index 8a14615..215f47a 100644 --- a/src/Watchtower.Application/Services/AppApiService.cs +++ b/src/Watchtower.Application/Services/AppApiService.cs @@ -70,6 +70,19 @@ public sealed record AppVersionServiceDto(string Service, string Image, string? public sealed record AppVersionDto( string? Commit, DateTimeOffset? DeployedAt, IReadOnlyList Services); +/// +/// One tenant the visiting user may switch to (GET /api/app/tenants/accessible). Deliberately just +/// enough to render a switcher entry: this response is shown to end users. +/// +/// Tenant identifier within the template. +/// Primary domain that tenant is served on. +/// True for exactly the stack answering the request. +public sealed record AppAccessibleTenantDto(string Slug, string Domain, bool Current); + +/// The sibling tenants of the caller's template the visiting user may enter, by slug ascending. +/// The accessible tenants, including the caller itself when the user may enter it. +public sealed record AppAccessibleTenantsDto(IReadOnlyList Tenants); + /// Error body returned by the App API for 400/401/403/404 responses. /// Human-readable message. Never contains tokens, credentials or deploy output. /// Available compose service names, when the error is "ambiguous service". diff --git a/src/Watchtower.Application/Services/AuthTokenSigner.cs b/src/Watchtower.Application/Services/AuthTokenSigner.cs index f09be59..99407d3 100644 --- a/src/Watchtower.Application/Services/AuthTokenSigner.cs +++ b/src/Watchtower.Application/Services/AuthTokenSigner.cs @@ -125,7 +125,39 @@ public string Mint(User user, string routeDomain) { /// UserInfo, where an app presents an assertion it itself received. /// /// and the subject id when valid; otherwise . - public bool TryValidate(string? token, out int userId) { + public bool TryValidate(string? token, out int userId) => + TryValidateCore(token, validAudiences: null, out userId); + + /// + /// Verifies an assertion exactly as does and additionally + /// binds it to the caller: its aud must name one of , compared + /// case-insensitively because host names are. + /// + /// + /// The overload the user-scoped discovery endpoints authenticate with, and the whole of their + /// anti-enumeration property: a stack passes the domains it is served on, so it can only ask + /// about a visitor who is actually standing in front of it. An assertion some other app was handed + /// carries that app's aud and is refused here, however cryptographically sound it is. + /// + /// The assertion the caller presented. + /// + /// Audiences to accept. An empty collection accepts nothing: "no domain to bind to" is not "any domain". + /// + /// The subject when the assertion is valid; otherwise zero. + /// and the subject id when valid; otherwise . + public bool TryValidate(string? token, IReadOnlyCollection validAudiences, out int userId) { + ArgumentNullException.ThrowIfNull(validAudiences); + userId = 0; + if (validAudiences.Count == 0) return false; + return TryValidateCore( + token, new HashSet(validAudiences, StringComparer.OrdinalIgnoreCase), out userId); + } + + /// + /// The one verification both overloads run. null means the audience is + /// not constrained (the UserInfo case); non-null means the aud must be one of them. + /// + private bool TryValidateCore(string? token, IReadOnlySet? validAudiences, out int userId) { userId = 0; if (string.IsNullOrWhiteSpace(token)) return false; @@ -133,8 +165,9 @@ public bool TryValidate(string? token, out int userId) { var parameters = new TokenValidationParameters { ValidIssuer = Issuer, ValidateIssuer = true, - // The aud binds a token to one app; at UserInfo any of ours is fine, so we do not constrain it. - ValidateAudience = false, + // The aud binds a token to one app. At UserInfo any of ours is fine, so it is left unconstrained; + // the discovery endpoints pass the caller's own domains and the delegate below decides. + ValidateAudience = validAudiences is not null, IssuerSigningKey = material.ValidationKey, ValidateIssuerSigningKey = true, // Pin the algorithm: the single line that closes off `alg: none` and key-confusion attacks. @@ -150,6 +183,15 @@ public bool TryValidate(string? token, out int userId) { }, }; + if (validAudiences is not null) { + // Not ValidAudiences: the library compares audiences ordinally, and a host name is not a + // case-sensitive string. A delegate replaces default audience processing entirely — and, unlike + // the flag above, is consulted whatever ValidateAudience says, so it cannot be switched off by + // a later edit that means to relax something else. + parameters.AudienceValidator = (audiences, _, _) => + audiences is not null && audiences.Any(validAudiences.Contains); + } + TokenValidationResult result; // ECDsa instance members are not thread-safe; serialise verification the same way signing is. lock (_gate) { diff --git a/src/Watchtower.Application/Services/MgmtApiService.cs b/src/Watchtower.Application/Services/MgmtApiService.cs index 0084b24..b6490e7 100644 --- a/src/Watchtower.Application/Services/MgmtApiService.cs +++ b/src/Watchtower.Application/Services/MgmtApiService.cs @@ -43,6 +43,19 @@ public sealed record MgmtTenantDto( /// The tenants. public sealed record MgmtTenantsDto(IReadOnlyList Tenants); +/// +/// One tenant of a managed template that the asserted user may enter +/// (GET …/tenants/accessible). No current flag: a management stack is not one of the tenants +/// it lists, so the field would have nothing to be true of. +/// +/// Tenant identifier within the template. +/// Primary domain that tenant is served on. +public sealed record MgmtAccessibleTenantDto(string Slug, string Domain); + +/// The managed template's tenants the asserted user may enter, by slug ascending. +/// The accessible tenants. +public sealed record MgmtAccessibleTenantsDto(IReadOnlyList Tenants); + /// A deploy that was accepted onto the queue. /// Deploy event id tracking it. /// One of queued or running. diff --git a/src/Watchtower.Application/Services/RouteAccessPolicy.cs b/src/Watchtower.Application/Services/RouteAccessPolicy.cs index c1f5d1b..7bbfb82 100644 --- a/src/Watchtower.Application/Services/RouteAccessPolicy.cs +++ b/src/Watchtower.Application/Services/RouteAccessPolicy.cs @@ -137,6 +137,27 @@ public static bool HasDotSegment(string path) { return false; } + /// What a route's means for a signed-in account. + private enum AccessDecision { + /// Not accessible, and nothing to look up — the fail-closed answer for an unknown mode. + Deny, + /// Accessible to any signed-in account. + Allow, + /// Accessible only with a for this route. + RequiresGrant, + } + + /// + /// The single reading of that both authorisation entry points below share, so + /// the per-route and the bulk answer cannot drift apart — a disagreement between them would be a hole. + /// + private static AccessDecision Classify(AccessMode mode) => mode switch { + AccessMode.Public or AccessMode.Authenticated => AccessDecision.Allow, + AccessMode.Restricted => AccessDecision.RequiresGrant, + // A mode this build does not know about is not a licence to let the request through. + _ => AccessDecision.Deny, + }; + /// /// Whether may enter . The account being valid and /// enabled is the caller's business; this answers only the policy question. @@ -144,16 +165,58 @@ public static bool HasDotSegment(string path) { public static async Task IsAuthorizedAsync( WatchtowerDbContext db, Route route, int userId, CancellationToken ct) { ArgumentNullException.ThrowIfNull(route); - return route.AccessMode switch { - AccessMode.Public => true, - AccessMode.Authenticated => true, - AccessMode.Restricted => await db.RouteAccessGrants.AsNoTracking() + return Classify(route.AccessMode) switch { + AccessDecision.Allow => true, + AccessDecision.RequiresGrant => await db.RouteAccessGrants.AsNoTracking() .AnyAsync(g => g.RouteId == route.Id && g.UserId == userId, ct), - // A mode this build does not know about is not a licence to let the request through. _ => false, }; } + /// + /// Which of may enter, as the set of their ids. + /// + /// + /// The bulk form of , and deliberately not a loop over it: the entire + /// restricted subset is settled by one indexed grants query, so answering for a template with fifty + /// tenants costs a single round trip rather than fifty. The set shape is what makes that structural — + /// a caller holding the whole answer has nothing left to ask per route. + /// + /// Database context to read grants through. + /// Candidate routes; duplicates and unknown modes are harmless. + /// The account being evaluated, already established as live and enabled. + /// Cancellation token. + /// The ids of the routes the account may enter; empty when it may enter none. + public static async Task> AccessibleRouteIdsAsync( + WatchtowerDbContext db, IReadOnlyList routes, int userId, CancellationToken ct) { + ArgumentNullException.ThrowIfNull(routes); + + var accessible = new HashSet(); + var restricted = new List(); + foreach (var route in routes) { + switch (Classify(route.AccessMode)) { + case AccessDecision.Allow: + accessible.Add(route.Id); + break; + case AccessDecision.RequiresGrant: + restricted.Add(route.Id); + break; + default: + // Fail-closed: an unrecognised mode contributes nothing, not even a grant lookup. + break; + } + } + + if (restricted.Count == 0) return accessible; + + var granted = await db.RouteAccessGrants.AsNoTracking() + .Where(g => g.UserId == userId && restricted.Contains(g.RouteId)) + .Select(g => g.RouteId) + .ToListAsync(ct); + accessible.UnionWith(granted); + return accessible; + } + /// /// Parses a candidate redirect_uri and returns it in normalised form, or /// when it is not an absolute https URL naming a bare host on the default port. diff --git a/src/Watchtower.Application/Services/TenantDiscoveryService.cs b/src/Watchtower.Application/Services/TenantDiscoveryService.cs new file mode 100644 index 0000000..66967c4 --- /dev/null +++ b/src/Watchtower.Application/Services/TenantDiscoveryService.cs @@ -0,0 +1,133 @@ +using Microsoft.EntityFrameworkCore; +using Watchtower.Application.Persistence; + +namespace Watchtower.Application.Services; + +/// +/// One tenant stack the proven user may reach. The never leaves the host: it is only +/// how a caller recognises its own row (current), so the wire DTOs carry slug and domain alone. +/// +/// Watchtower's numeric id for the tenant's stack. +/// Tenant identifier within the template. +/// Primary domain the tenant is served on. +public sealed record AccessibleTenant(int StackId, string Slug, string Domain); + +/// +/// User-scoped tenant discovery: which sibling tenants of a template the visitor currently standing in +/// front of a stack may switch to. Backs GET /api/app/tenants/accessible and +/// GET /api/mgmt/templates/{id}/tenants/accessible. +/// +/// +/// +/// This is the one place a stack learns anything about its siblings, and it is a scoped amendment of +/// ADR-0008's invariant rather than a hole in it: a stack still cannot enumerate the estate on its own, it +/// can only ask what the authenticated user in front of it may reach. The user is proven by the +/// Watchtower-signed assertion the stack was forwarded on the request it is serving — never by a user id in +/// the request — and the assertion's aud must be one of the calling stack's own domains, so an +/// assertion picked up elsewhere buys nothing. +/// +/// +/// The answer carries no stack ids, no status, no timestamps and no environment data: it is rendered to end +/// users in a tenant switcher, so it holds exactly what is needed to offer the switch. +/// +/// +/// Scoped Watchtower database context. +/// Verifies the forwarded identity assertion against the calling stack's domains. +public sealed class TenantDiscoveryService(WatchtowerDbContext db, AuthTokenSigner signer) { + /// + /// The template is a tenant of, or when it is not a + /// tenant at all. + /// + /// + /// A tenant is a stack with both a template and a slug — the same definition the management API's + /// listing uses, so a half-provisioned row is not treated as one here and skipped there. + /// + /// The stack asking about its siblings. + /// Cancellation token. + /// The template id, or null when the stack is standalone. + public Task ResolveTenantTemplateIdAsync(int stackId, CancellationToken ct) => + db.Stacks.AsNoTracking() + .Where(s => s.Id == stackId && s.TenantSlug != null) + .Select(s => s.TemplateId) + .FirstOrDefaultAsync(ct); + + /// + /// Resolves the account a forwarded X-Watchtower-Jwt assertion proves, bound to the calling + /// stack's own domains, or when it proves nothing usable. + /// + /// + /// Three checks, and the caller must not be able to tell which one refused it. The signature, algorithm, + /// issuer and expiry are the signer's; the aud must be a domain + /// is itself served on, which is what stops a stack asking about users it has never seen; and the account + /// is then reloaded, because the assertion is a five-minute-old statement while access is answered as of + /// now — an account disabled in between must resolve to nothing. + /// + /// The authenticated stack, whose route domains are the accepted audiences. + /// Raw assertion from the request header; may be null or empty. + /// Cancellation token. + /// The proven user's id, or null. + public async Task ResolveAssertionSubjectAsync( + int callerStackId, string? assertion, CancellationToken ct) { + var audiences = await db.Routes.AsNoTracking() + .Where(r => r.StackId == callerStackId) + .Select(r => r.Domain) + .ToListAsync(ct); + + // A stack Watchtower serves no domain for cannot have been forwarded an assertion in the first + // place, and the signer refuses an empty audience set rather than reading it as "any". + if (!signer.TryValidate(assertion, audiences, out var userId)) return null; + + var account = await db.Users.AsNoTracking() + .Where(u => u.Id == userId) + .Select(u => new { u.Id, u.Disabled }) + .FirstOrDefaultAsync(ct); + return account is null || account.Disabled ? null : account.Id; + } + + /// + /// The tenants of that may enter, by slug + /// ascending. + /// + /// + /// Access is decided on each tenant's primary route through + /// , in one grants query for the whole template. + /// A tenant with no primary route is omitted: there is no domain to switch to, so offering it would only + /// produce a dead entry in the switcher. + /// + /// Template whose tenants are being listed. + /// The proven user, already established as live and enabled. + /// Cancellation token. + /// The accessible tenants, ordered by slug. + public async Task> ListAccessibleTenantsAsync( + int templateId, int userId, CancellationToken ct) { + var tenants = await db.Stacks.AsNoTracking() + .Where(s => s.TemplateId == templateId && s.TenantSlug != null) + .Select(s => new { s.Id, Slug = s.TenantSlug! }) + .ToListAsync(ct); + if (tenants.Count == 0) return []; + + var stackIds = tenants.Select(t => t.Id).ToList(); + var routes = await db.Routes.AsNoTracking() + .Where(r => stackIds.Contains(r.StackId) && r.IsPrimary) + .ToListAsync(ct); + + // One route per tenant, lowest id first: should a stack somehow carry two primary rows, which one + // decides access must not depend on the order the provider happened to return them in. + var primary = routes + .GroupBy(r => r.StackId) + .ToDictionary(g => g.Key, g => g.OrderBy(r => r.Id).First()); + + var accessible = await RouteAccessPolicy.AccessibleRouteIdsAsync(db, [.. primary.Values], userId, ct); + + var reachable = new List(tenants.Count); + foreach (var tenant in tenants) { + if (!primary.TryGetValue(tenant.Id, out var route) || !accessible.Contains(route.Id)) continue; + reachable.Add(new AccessibleTenant(tenant.Id, tenant.Slug, route.Domain)); + } + + // Ordinal, and sorted here rather than in SQL: slugs are already normalised to lowercase ASCII, and + // a culture-sensitive comparison would make the order depend on the host's locale. + reachable.Sort((left, right) => string.CompareOrdinal(left.Slug, right.Slug)); + return reachable; + } +} diff --git a/src/Watchtower.Application/Services/TenantProvisioningService.cs b/src/Watchtower.Application/Services/TenantProvisioningService.cs index 887b2cb..b229518 100644 --- a/src/Watchtower.Application/Services/TenantProvisioningService.cs +++ b/src/Watchtower.Application/Services/TenantProvisioningService.cs @@ -1,3 +1,4 @@ +using System.Collections.Frozen; using Microsoft.EntityFrameworkCore; using Watchtower.Application.Entities; using Watchtower.Application.Modules.Tenancy; @@ -62,6 +63,21 @@ public sealed class TenantProvisioningService( /// What the initial deploy of a freshly provisioned tenant is recorded as. public const string CreateTrigger = "tenant-create"; + /// + /// Slugs no tenant may be provisioned under, because a management-API path segment already spells them. + /// + /// + /// accessible is here because GET …/tenants/accessible is a literal route, and ASP.NET + /// routing ranks a literal segment above the {slug} parameter — a tenant slugged + /// accessible would therefore be unreachable on the tenant-status route. Reserving the word at + /// provisioning makes that collision impossible going forward; it deliberately does not touch + /// , so any slug already stored keeps resolving exactly as it + /// did (normalisation says what a slug is; this says which ones we hand out). A future literal + /// segment under …/tenants/ belongs in this set on the day it is added. + /// + public static readonly IReadOnlySet ReservedSlugs = + FrozenSet.ToFrozenSet(["accessible"], StringComparer.OrdinalIgnoreCase); + /// /// Provisions one tenant of . /// @@ -76,6 +92,9 @@ public async Task ProvisionAsync( if (normalized is null) return Failed(TenantProvisionStatus.Validation, "Slug must start with a letter or digit and contain only lowercase letters, digits, and hyphens."); + // Checked on the normalized value, so "Accessible" is refused the same way "accessible" is. + if (ReservedSlugs.Contains(normalized)) + return Failed(TenantProvisionStatus.Validation, $"Slug '{normalized}' is reserved."); if (envOverrides is { Count: > 0 } && TenancyMapping.FirstDuplicateKey(envOverrides) is { } dup) return Failed(TenantProvisionStatus.Validation, $"Duplicate env var key: '{dup}'"); diff --git a/src/Watchtower.Application/WatchtowerServiceCollectionExtensions.cs b/src/Watchtower.Application/WatchtowerServiceCollectionExtensions.cs index d438072..90dc538 100644 --- a/src/Watchtower.Application/WatchtowerServiceCollectionExtensions.cs +++ b/src/Watchtower.Application/WatchtowerServiceCollectionExtensions.cs @@ -63,6 +63,10 @@ public static IServiceCollection AddWatchtowerServices(this IServiceCollection s // Public management API (/api/mgmt/*): App API token auth + grant resolution + the tenant // read models the host endpoints translate. services.AddScoped(); + // User-scoped tenant discovery behind both public surfaces' /tenants/accessible endpoints: verifies + // the forwarded identity assertion against the calling stack's own domains, then filters the + // template's tenants by what that user may enter. + services.AddScoped(); // Elarion settings — typed key/value store backed by the EF Setting entity. Replaces the // hand-rolled SettingsStore; used for self-update config/runtime state and the runtime-editable