From ae32475c966e0cf1dd3dcf0f2b3114c2ab12727b Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 11:26:52 +0200 Subject: [PATCH 01/32] feat(release): prepare API docs and tooling delivery --- .github/workflows/pull-request-validation.yml | 32 ++ .github/workflows/release.yml | 80 +++- README.md | 10 + docker/docker-compose.mcp.release.yml | 23 ++ docker/docker-compose.mcp.yml | 25 ++ docker/docker-compose.release.yml | 4 +- docker/examples/mcp-http/.env.example | 7 + docker/examples/mcp-http/README.md | 33 ++ docker/examples/mcp-http/config.example.json | 5 + docker/examples/mcp-http/docker-compose.yml | 20 + docker/mcp-http/Dockerfile | 9 +- src/HelpDesk.NewWeb/Program.cs | 2 +- ...egrationCredentialAuthenticationHandler.cs | 75 ++++ .../Documentation/RatelDeskOpenApiCatalog.cs | 73 ++++ .../IntegrationCredentialEndpoints.cs | 102 +++++ src/Helpdesk.API/Program.cs | 10 + .../AgentClientConfiguration.cs | 22 +- .../HelpdeskAgentClient.cs | 15 +- src/Helpdesk.Cli/CliConfig.cs | 33 +- src/Helpdesk.Cli/CliRuntime.cs | 8 +- src/Helpdesk.Cli/Helpdesk.Cli.csproj | 1 + src/Helpdesk.Cli/HelpdeskCli.Auth.cs | 14 +- src/Helpdesk.Cli/HelpdeskCli.Transport.cs | 15 +- src/Helpdesk.Cli/Program.cs | 12 +- .../Auth/Rbac/CurrentUserAccessService.cs | 42 +- .../Identity/IntegrationCredential.cs | 21 + ...1844_AddIntegrationCredentials.Designer.cs | 363 ++++++++++++++++++ ...0260917091844_AddIntegrationCredentials.cs | 54 +++ ...RatelDeskIdentityDbContextModelSnapshot.cs | 61 +++ .../Identity/RatelDeskIdentityDbContext.cs | 17 + .../Configuration/HelpdeskMcpTarget.cs | 17 +- src/Helpdesk.Mcp/Helpdesk.Mcp.csproj | 1 + src/Helpdesk.Mcp/Program.cs | 10 + .../Api/OpenApiAndVersionEndpointsTests.cs | 5 + tools/release/package-assets.sh | 75 ++++ 35 files changed, 1248 insertions(+), 48 deletions(-) create mode 100644 docker/docker-compose.mcp.release.yml create mode 100644 docker/docker-compose.mcp.yml create mode 100644 docker/examples/mcp-http/.env.example create mode 100644 docker/examples/mcp-http/README.md create mode 100644 docker/examples/mcp-http/config.example.json create mode 100644 docker/examples/mcp-http/docker-compose.yml create mode 100644 src/Helpdesk.API/Authentication/IntegrationCredentialAuthenticationHandler.cs create mode 100644 src/Helpdesk.API/Documentation/RatelDeskOpenApiCatalog.cs create mode 100644 src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs create mode 100644 src/Helpdesk.Infrastructure/Identity/IntegrationCredential.cs create mode 100644 src/Helpdesk.Infrastructure/Identity/Migrations/20260917091844_AddIntegrationCredentials.Designer.cs create mode 100644 src/Helpdesk.Infrastructure/Identity/Migrations/20260917091844_AddIntegrationCredentials.cs create mode 100755 tools/release/package-assets.sh diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 06716962..6dd19536 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -173,6 +173,38 @@ jobs: if: matrix.name == 'web' run: tools/ci/smoke-web-static-assets.sh "${{ matrix.image }}" + release-assets: + name: Release asset rehearsal + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Check out triggering commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up .NET SDK from global.json + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + global-json-file: global.json + + - name: Assemble self-contained CLI, stdio MCP, and deployment assets + run: tools/release/package-assets.sh 0.0.0-pr "${{ github.sha }}" release-assets + + - name: Verify asset manifest and checksums + run: | + test "$(find release-assets -maxdepth 1 -name 'rateldesk-cli-*.tar.gz' | wc -l)" = 4 + test "$(find release-assets -maxdepth 1 -name 'rateldesk-cli-*.zip' | wc -l)" = 1 + test "$(find release-assets -maxdepth 1 -name 'rateldesk-mcp-stdio-*.tar.gz' | wc -l)" = 4 + test "$(find release-assets -maxdepth 1 -name 'rateldesk-mcp-stdio-*.zip' | wc -l)" = 1 + (cd release-assets && sha256sum --check SHA256SUMS) + test -s release-assets/release-manifest.json + + - name: Upload release rehearsal assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-asset-rehearsal + path: release-assets + retention-days: 14 + compose: name: Compose startup validation runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ab13bc8..43534ff5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,8 +108,16 @@ jobs: --build-arg VERSION --build-arg SOURCE_REVISION_ID --build-arg BUILD_TIMESTAMP . docker build --file docker/web/Dockerfile --tag rateldesk-web:release-validation \ --build-arg VERSION --build-arg SOURCE_REVISION_ID --build-arg BUILD_TIMESTAMP . + docker build --file docker/mcp-http/Dockerfile --tag rateldesk-mcp-http:release-validation \ + --build-arg VERSION --build-arg SOURCE_REVISION_ID --build-arg BUILD_TIMESTAMP . tools/ci/smoke-web-static-assets.sh rateldesk-web:release-validation + - name: Rehearse executable and deployment assets + run: tools/release/package-assets.sh "${{ needs.metadata.outputs.version }}" "${{ needs.metadata.outputs.source_revision }}" release-assets + + - name: Verify executable asset checksums + run: (cd release-assets && sha256sum --check SHA256SUMS) + - name: Start and smoke-test source Compose stack run: | docker compose -f docker/docker-compose.yml config --quiet @@ -249,9 +257,71 @@ jobs: provenance: mode=max sbom: true + publish-mcp-http: + name: Publish HTTP MCP image + needs: [metadata, validation] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + attestations: write + outputs: + digest: ${{ steps.push.outputs.digest }} + steps: + - name: Check out release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up QEMU + uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create image tags and labels + id: image + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ghcr.io/bostontechnologies/rateldesk-mcp-http + tags: | + type=raw,value=${{ needs.metadata.outputs.version }} + type=raw,value=latest,enable=${{ needs.metadata.outputs.prerelease == 'false' }} + labels: | + org.opencontainers.image.title=RatelDesk HTTP MCP + org.opencontainers.image.description=RatelDesk stateless HTTP MCP transport + org.opencontainers.image.source=https://github.com/BostonTechnologies/RatelDesk + org.opencontainers.image.revision=${{ needs.metadata.outputs.source_revision }} + org.opencontainers.image.version=${{ needs.metadata.outputs.version }} + org.opencontainers.image.created=${{ needs.metadata.outputs.build_timestamp }} + org.opencontainers.image.licenses=Apache-2.0 + + - name: Build and push multi-platform image + id: push + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: docker/mcp-http/Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.image.outputs.tags }} + labels: ${{ steps.image.outputs.labels }} + build-args: | + VERSION=${{ needs.metadata.outputs.version }} + SOURCE_REVISION_ID=${{ needs.metadata.outputs.source_revision }} + BUILD_TIMESTAMP=${{ needs.metadata.outputs.build_timestamp }} + provenance: mode=max + sbom: true + release: name: Create GitHub Release - needs: [metadata, publish-web, publish-api] + needs: [metadata, publish-web, publish-api, publish-mcp-http] runs-on: ubuntu-latest permissions: contents: write @@ -268,7 +338,7 @@ jobs: VERSION: ${{ needs.metadata.outputs.version }} run: | set -euo pipefail - for image in rateldesk-web rateldesk-api; do + for image in rateldesk-web rateldesk-api rateldesk-mcp-http; do test "$(gh api "orgs/BostonTechnologies/packages/container/$image" --jq '.visibility')" = public test "$(gh api "orgs/BostonTechnologies/packages/container/$image" --jq '.repository.full_name')" = BostonTechnologies/RatelDesk docker pull "ghcr.io/bostontechnologies/$image:$VERSION" @@ -283,6 +353,7 @@ jobs: BUILD_TIMESTAMP: ${{ needs.metadata.outputs.build_timestamp }} WEB_DIGEST: ${{ needs.publish-web.outputs.digest }} API_DIGEST: ${{ needs.publish-api.outputs.digest }} + MCP_HTTP_DIGEST: ${{ needs.publish-mcp-http.outputs.digest }} run: | set -euo pipefail prerelease_flag=() @@ -295,18 +366,21 @@ jobs: ghcr.io/bostontechnologies/rateldesk-web:$VERSION ghcr.io/bostontechnologies/rateldesk-api:$VERSION + ghcr.io/bostontechnologies/rateldesk-mcp-http:$VERSION Digests Web: $WEB_DIGEST API: $API_DIGEST + HTTP MCP: $MCP_HTTP_DIGEST Source commit: $SOURCE_REVISION Built: $BUILD_TIMESTAMP Pull: docker pull ghcr.io/bostontechnologies/rateldesk-web:$VERSION - docker pull ghcr.io/bostontechnologies/rateldesk-api:$VERSION" + docker pull ghcr.io/bostontechnologies/rateldesk-api:$VERSION + docker pull ghcr.io/bostontechnologies/rateldesk-mcp-http:$VERSION" if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then gh release edit "$GITHUB_REF_NAME" \ diff --git a/README.md b/README.md index 5208576b..b81470b1 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,16 @@ The main public configuration surfaces are: See [self-hosting guidance](docs/SELF_HOSTING.md) for configuration, Docker, reverse-proxy, identity, email, AI, MCP, orchestration, telemetry, and troubleshooting guidance. See [instance branding](docs/branding.md) to customise the customer-facing identity without forking RatelDesk. +## API, CLI, and MCP + +The Web-hosted [RatelDesk API reference](/api/docs) groups the public API by product area and sends Try It requests through the established `/api` proxy. Local account login is a browser-cookie flow with CSRF protection; a browser cookie is not a CLI or MCP Bearer credential. + +For automation, sign in normally, complete configured MFA, then create a bounded API integration credential through `POST /api/v1/integration-credentials`. The secret is returned only by the create response. Store it in a protected configuration file or secret mount. Its effective access is always the intersection of the account's current authorization, the credential's selected permissions, and its organization scope; revocation and account disablement take effect on later requests. + +GitHub Releases contain self-contained `rateldesk` CLI and `rateldesk-mcp` stdio MCP archives for Linux x64/arm64, Windows x64, and macOS x64/arm64. The Linux archives target glibc distributions, not Alpine/musl. Both executables support offline `--help` and `--version` before loading credentials. + +HTTP MCP is optional and never joins the base Web/API stack. The source and release overlays are documented in [the HTTP MCP example](docker/examples/mcp-http/README.md). The existing Authentik HTTP MCP mode remains a separately configured external identity integration; it is not a fallback for local credentials. + ## Contributing and security Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a change. Report vulnerabilities privately according to [SECURITY.md](SECURITY.md). diff --git a/docker/docker-compose.mcp.release.yml b/docker/docker-compose.mcp.release.yml new file mode 100644 index 00000000..42ef3cd8 --- /dev/null +++ b/docker/docker-compose.mcp.release.yml @@ -0,0 +1,23 @@ +name: rateldesk + +# Optional image-only overlay. It never creates a source build. +services: + mcp-http: + image: ghcr.io/bostontechnologies/rateldesk-mcp-http:${RATELDESK_VERSION:?Set RATELDESK_VERSION to an exact published RatelDesk version.} + environment: + ASPNETCORE_ENVIRONMENT: Production + RATELDESK_MCP_CONFIG: /run/rateldesk-mcp/config.json + Helpdesk__Mcp__Instance: ${RATELDESK_MCP_INSTANCE:-local} + Helpdesk__Mcp__ExpectedApiBaseUrl: http://api:8222/ + Helpdesk__Mcp__PublicResourceUri: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical HTTPS MCP URL, for example https://mcp.example.test/mcp.} + Helpdesk__Mcp__AllowedOrigins: ${RATELDESK_MCP_ALLOWED_ORIGINS:-} + volumes: + - type: bind + source: ${RATELDESK_MCP_CONFIG_FILE:?Set RATELDESK_MCP_CONFIG_FILE to a protected configuration file.} + target: /run/rateldesk-mcp/config.json + read_only: true + depends_on: + api: + condition: service_healthy + ports: + - "127.0.0.1:${RATELDESK_MCP_PORT:-8223}:8223" diff --git a/docker/docker-compose.mcp.yml b/docker/docker-compose.mcp.yml new file mode 100644 index 00000000..cbb3b6e3 --- /dev/null +++ b/docker/docker-compose.mcp.yml @@ -0,0 +1,25 @@ +name: rateldesk + +# Optional source-build overlay. The base compose topology remains Web + API. +services: + mcp-http: + build: + context: .. + dockerfile: docker/mcp-http/Dockerfile + environment: + ASPNETCORE_ENVIRONMENT: Production + RATELDESK_MCP_CONFIG: /run/rateldesk-mcp/config.json + Helpdesk__Mcp__Instance: ${RATELDESK_MCP_INSTANCE:-local} + Helpdesk__Mcp__ExpectedApiBaseUrl: http://api:8222/ + Helpdesk__Mcp__PublicResourceUri: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical HTTPS MCP URL, for example https://mcp.example.test/mcp.} + Helpdesk__Mcp__AllowedOrigins: ${RATELDESK_MCP_ALLOWED_ORIGINS:-} + volumes: + - type: bind + source: ${RATELDESK_MCP_CONFIG_FILE:?Set RATELDESK_MCP_CONFIG_FILE to a protected configuration file.} + target: /run/rateldesk-mcp/config.json + read_only: true + depends_on: + api: + condition: service_healthy + ports: + - "127.0.0.1:${RATELDESK_MCP_PORT:-8223}:8223" diff --git a/docker/docker-compose.release.yml b/docker/docker-compose.release.yml index a2573ed3..27796f5d 100644 --- a/docker/docker-compose.release.yml +++ b/docker/docker-compose.release.yml @@ -3,7 +3,7 @@ name: rateldesk services: api: restart: unless-stopped - image: ghcr.io/bostontechnologies/rateldesk-api:${RATELDESK_VERSION:-0.1.0-rc.5} + image: ghcr.io/bostontechnologies/rateldesk-api:${RATELDESK_VERSION:?Set RATELDESK_VERSION to an exact published RatelDesk version.} environment: ASPNETCORE_ENVIRONMENT: Production Authentication__Mode: Local @@ -27,7 +27,7 @@ services: web: restart: unless-stopped - image: ghcr.io/bostontechnologies/rateldesk-web:${RATELDESK_VERSION:-0.1.0-rc.5} + image: ghcr.io/bostontechnologies/rateldesk-web:${RATELDESK_VERSION:?Set RATELDESK_VERSION to an exact published RatelDesk version.} environment: ASPNETCORE_ENVIRONMENT: Production ApiBaseUrl: http://api:8222/ diff --git a/docker/examples/mcp-http/.env.example b/docker/examples/mcp-http/.env.example new file mode 100644 index 00000000..725c567f --- /dev/null +++ b/docker/examples/mcp-http/.env.example @@ -0,0 +1,7 @@ +# Copy to .env and replace every example value. Do not commit the resulting file. +RATELDESK_VERSION=0.1.0-rc.9 +RATELDESK_MCP_INSTANCE=example +RATELDESK_API_BASE_URL=https://api.example.test/ +RATELDESK_MCP_PUBLIC_RESOURCE_URI=https://mcp.example.test/mcp +RATELDESK_MCP_CONFIG_FILE=./config.json +RATELDESK_MCP_PORT=8223 diff --git a/docker/examples/mcp-http/README.md b/docker/examples/mcp-http/README.md new file mode 100644 index 00000000..13bfc367 --- /dev/null +++ b/docker/examples/mcp-http/README.md @@ -0,0 +1,33 @@ +# Optional HTTP MCP transport + +This example is an optional, stateless HTTP MCP transport for an existing +RatelDesk API. It does not start an API or a database. Create an MCP-targeted +integration credential after completing normal RatelDesk setup and sign-in, +then save it in `config.json` with owner-only permissions: + +```sh +cp .env.example .env +cp config.example.json config.json +chmod 600 config.json +docker compose up -d +``` + +Set `RATELDESK_API_BASE_URL` to the exact API target and +`RATELDESK_MCP_PUBLIC_RESOURCE_URI` to the public HTTPS `/mcp` URL. Configure +the remote MCP client with that URL and its Bearer credential; local account +cookies are browser sessions, not MCP credentials. The MCP container receives +only its protected configuration file and does not mount the API database or +data-protection key ring. + +For a source checkout, run from the repository root: + +```sh +docker compose -f docker/docker-compose.yml -f docker/docker-compose.mcp.yml up --build +``` + +For release images, supply the one selected version to both files: + +```sh +RATELDESK_VERSION=0.1.0-rc.9 \ + docker compose -f docker/docker-compose.release.yml -f docker/docker-compose.mcp.release.yml up -d +``` diff --git a/docker/examples/mcp-http/config.example.json b/docker/examples/mcp-http/config.example.json new file mode 100644 index 00000000..4ae19dfb --- /dev/null +++ b/docker/examples/mcp-http/config.example.json @@ -0,0 +1,5 @@ +{ + "apiBaseUrl": "https://api.example.test/", + "credentialMode": "integration", + "integrationCredential": "replace-with-an-mcp-targeted-credential" +} diff --git a/docker/examples/mcp-http/docker-compose.yml b/docker/examples/mcp-http/docker-compose.yml new file mode 100644 index 00000000..d6575b87 --- /dev/null +++ b/docker/examples/mcp-http/docker-compose.yml @@ -0,0 +1,20 @@ +name: rateldesk-mcp-http + +# Connects to an already-running API; it deliberately has no depends_on entry. +services: + mcp-http: + image: ghcr.io/bostontechnologies/rateldesk-mcp-http:${RATELDESK_VERSION:?Set RATELDESK_VERSION to an exact published RatelDesk version.} + environment: + ASPNETCORE_ENVIRONMENT: Production + RATELDESK_MCP_CONFIG: /run/rateldesk-mcp/config.json + Helpdesk__Mcp__Instance: ${RATELDESK_MCP_INSTANCE:-example} + Helpdesk__Mcp__ExpectedApiBaseUrl: ${RATELDESK_API_BASE_URL:?Set the pinned API URL.} + Helpdesk__Mcp__PublicResourceUri: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical HTTPS MCP URL.} + Helpdesk__Mcp__AllowedOrigins: ${RATELDESK_MCP_ALLOWED_ORIGINS:-} + volumes: + - type: bind + source: ${RATELDESK_MCP_CONFIG_FILE:?Set the protected MCP configuration file.} + target: /run/rateldesk-mcp/config.json + read_only: true + ports: + - "127.0.0.1:${RATELDESK_MCP_PORT:-8223}:8223" diff --git a/docker/mcp-http/Dockerfile b/docker/mcp-http/Dockerfile index 6c936cd5..a1c20f16 100644 --- a/docker/mcp-http/Dockerfile +++ b/docker/mcp-http/Dockerfile @@ -10,8 +10,12 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD wget FROM mcr.microsoft.com/dotnet/sdk:10.0.401-alpine3.23 AS build WORKDIR /src ARG BUILD_CONFIGURATION=Release +ARG VERSION +ARG SOURCE_REVISION_ID +ARG BUILD_TIMESTAMP COPY global.json ./ +COPY Directory.Build.props Directory.Build.targets ./ COPY src/Helpdesk.AgentClient/*.csproj src/Helpdesk.AgentClient/ COPY src/Helpdesk.Mcp.Core/*.csproj src/Helpdesk.Mcp.Core/ COPY src/Helpdesk.Mcp.Http/*.csproj src/Helpdesk.Mcp.Http/ @@ -27,7 +31,10 @@ COPY src/Helpdesk.Shared/ src/Helpdesk.Shared/ RUN mkdir /out \ && dotnet publish src/Helpdesk.Mcp.Http/Helpdesk.Mcp.Http.csproj \ -c $BUILD_CONFIGURATION -o /out --no-restore -v:n \ - /p:UseAppHost=false + /p:UseAppHost=false \ + /p:Version=$VERSION \ + /p:SourceRevisionId=$SOURCE_REVISION_ID \ + /p:BuildTimestamp=$BUILD_TIMESTAMP FROM base AS final WORKDIR /app diff --git a/src/HelpDesk.NewWeb/Program.cs b/src/HelpDesk.NewWeb/Program.cs index add1ad00..0b1ac87e 100644 --- a/src/HelpDesk.NewWeb/Program.cs +++ b/src/HelpDesk.NewWeb/Program.cs @@ -389,7 +389,7 @@ { var request = context.Request; var openApiUrl = $"{request.Scheme}://{request.Host}{request.PathBase}/api/openapi/v1.json"; - options.AddDocument("v1", "Helpdesk API", openApiUrl, isDefault: true); + options.AddDocument("v1", "RatelDesk API", openApiUrl, isDefault: true); options.Servers = new[] { new ScalarServer("/api") }; }).AllowAnonymous(); diff --git a/src/Helpdesk.API/Authentication/IntegrationCredentialAuthenticationHandler.cs b/src/Helpdesk.API/Authentication/IntegrationCredentialAuthenticationHandler.cs new file mode 100644 index 00000000..6c6a2e54 --- /dev/null +++ b/src/Helpdesk.API/Authentication/IntegrationCredentialAuthenticationHandler.cs @@ -0,0 +1,75 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Helpdesk.Infrastructure.Identity; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Helpdesk.API.Authentication; + +public sealed class IntegrationCredentialAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + System.Text.Encodings.Web.UrlEncoder encoder, + RatelDeskIdentityDbContext identityDb) + : AuthenticationHandler(options, logger, encoder) +{ + public const string SchemeName = "IntegrationCredential"; + public const string ApiPurpose = "api"; + public const string McpPurpose = "mcp"; + + protected override async Task HandleAuthenticateAsync() + { + var authorization = Request.Headers.Authorization.ToString(); + if (!authorization.StartsWith("Bearer rdk_", StringComparison.OrdinalIgnoreCase)) + return AuthenticateResult.NoResult(); + + var token = authorization["Bearer ".Length..].Trim(); + var segments = token.Split('_', 3, StringSplitOptions.None); + if (segments.Length != 3 || !Guid.TryParseExact(segments[1], "N", out var credentialId) || string.IsNullOrWhiteSpace(segments[2])) + return AuthenticateResult.Fail("The integration credential is malformed."); + + var credential = await identityDb.IntegrationCredentials + .SingleOrDefaultAsync(candidate => candidate.Id == credentialId, Context.RequestAborted) + .ConfigureAwait(false); + if (credential is null || + credential.RevokedAtUtc is not null || + credential.ExpiresAtUtc <= DateTimeOffset.UtcNow || + !string.Equals(credential.Purpose, ApiPurpose, StringComparison.Ordinal)) + { + return AuthenticateResult.Fail("The integration credential is not valid for this API."); + } + + var providedVerifier = SHA256.HashData(Encoding.UTF8.GetBytes(segments[2])); + var storedVerifier = Convert.FromHexString(credential.SecretHash); + if (!CryptographicOperations.FixedTimeEquals(providedVerifier, storedVerifier)) + return AuthenticateResult.Fail("The integration credential is not valid."); + + var owner = await identityDb.Users.AsNoTracking() + .SingleOrDefaultAsync(user => user.Id == credential.OwnerUserId, Context.RequestAborted) + .ConfigureAwait(false); + if (owner?.IsEnabled != true) + return AuthenticateResult.Fail("The integration credential owner is disabled."); + + credential.LastUsedAtUtc = DateTimeOffset.UtcNow; + await identityDb.SaveChangesAsync(Context.RequestAborted).ConfigureAwait(false); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, owner.Id), + new(ClaimTypes.Name, string.IsNullOrWhiteSpace(owner.DisplayName) ? owner.UserName ?? owner.Email ?? owner.Id : owner.DisplayName), + new(ClaimTypes.Email, owner.Email ?? string.Empty), + new("auth_mode", "integration"), + new("integration_credential_id", credential.Id.ToString("N")), + new("integration_purpose", credential.Purpose) + }; + if (!string.IsNullOrWhiteSpace(credential.OrganizationId)) + claims.Add(new Claim("integration_organization_id", credential.OrganizationId)); + foreach (var permission in credential.Permissions.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + claims.Add(new Claim("integration_permission", permission)); + + var identity = new ClaimsIdentity(claims, SchemeName, ClaimTypes.Name, ClaimTypes.Role); + return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName)); + } +} diff --git a/src/Helpdesk.API/Documentation/RatelDeskOpenApiCatalog.cs b/src/Helpdesk.API/Documentation/RatelDeskOpenApiCatalog.cs new file mode 100644 index 00000000..a56ada29 --- /dev/null +++ b/src/Helpdesk.API/Documentation/RatelDeskOpenApiCatalog.cs @@ -0,0 +1,73 @@ +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.OpenApi; + +namespace Helpdesk.API.Documentation; + +/// Single source of truth for the public API reference navigation. +public static class RatelDeskOpenApiCatalog +{ + private sealed record Tag(string Name, string Group, string Description); + + private static readonly Tag[] Tags = + [ + new("Setup", "Getting Started", "Bootstrap and setup state."), + new("Authentication", "Identity & Access", "Current identity and authentication integrations."), + new("Local Authentication", "Identity & Access", "Browser cookie sign-in and MFA for local accounts."), + new("Customer Authentication", "Identity & Access", "Customer identity linking."), + new("Users", "Identity & Access", "Application users and access."), + new("Role Definitions", "Identity & Access", "Scoped application role definitions."), + new("Integration Credentials", "Identity & Access", "Revocable API credentials. Secrets are shown once."), + new("Organizations", "Organizations & Customers", "Application tenant organizations."), + new("Customers", "Organizations & Customers", "Contacts; a customer is not the application tenant."), + new("Tenant Administration", "Organizations & Customers", "Tenant-scoped administration."), + new("Tenant Settings", "Organizations & Customers", "Tenant configuration."), + new("Tenant Branding", "Organizations & Customers", "Tenant branding."), + new("Support Groups", "Organizations & Customers", "Support groups."), + new("Support Group Members", "Organizations & Customers", "Support group membership."), + new("Support Coverage", "Organizations & Customers", "Support coverage."), + new("Tickets", "Ticketing", "Shared ticket operations."), new("Incidents", "Ticketing", "Incident tickets."), new("Requests", "Ticketing", "Service requests."), new("Request Tasks", "Ticketing", "Request tasks."), new("Request Approvals", "Ticketing", "Request approvals."), new("Changes", "Ticketing", "Change tickets."), new("Work Logs", "Ticketing", "Ticket work logs."), new("Attachments", "Ticketing", "Ticket attachments."), new("Timeline", "Ticketing", "Ticket timeline."), + new("Services", "Service Catalog & Self-Service", "Service catalog."), new("Request Forms", "Service Catalog & Self-Service", "Request forms."), new("Ticket Categories", "Service Catalog & Self-Service", "Ticket categories."), new("Ticket Lookups", "Service Catalog & Self-Service", "Ticket lookup values."), new("Self-Service", "Service Catalog & Self-Service", "Customer self-service."), new("Public Tickets", "Service Catalog & Self-Service", "Public ticket submission."), new("CAPTCHA", "Service Catalog & Self-Service", "CAPTCHA verification."), + new("SLA Policies", "Service Levels", "SLA policies."), new("SLA Calendars", "Service Levels", "Working calendars."), new("Tenant SLA Settings", "Service Levels", "Tenant SLA settings."), new("Ticket SLA", "Service Levels", "Ticket SLA state."), new("SLA Reports", "Service Levels", "SLA reporting."), new("SLA Report Subscriptions", "Service Levels", "SLA report subscriptions."), + new("Email Settings", "Email & Notifications", "Email settings."), new("Email Processing", "Email & Notifications", "Email processing."), new("Inbound Email Rules", "Email & Notifications", "Inbound email rules."), new("Email Layouts", "Email & Notifications", "Email layouts."), new("Email Templates", "Email & Notifications", "Email templates."), new("Notifications", "Email & Notifications", "Notifications."), new("Support Notification Subscriptions", "Email & Notifications", "Support notification subscriptions."), new("Support Notification Preferences", "Email & Notifications", "Support notification preferences."), + new("Automation Rules", "Automation & Integrations", "Automation rules."), new("Workflow Operations", "Automation & Integrations", "Workflow operations."), new("External Orchestration", "Automation & Integrations", "External orchestration."), new("Orchestration Provider", "Automation & Integrations", "Orchestration provider."), + new("AI Assistant", "AI Assistant", "AI assistant operations."), new("AI Assistant Chat", "AI Assistant", "AI assistant chat."), new("AI Assistant Webhooks", "AI Assistant", "AI assistant webhooks."), new("AI Assistant MCP", "AI Assistant", "Application MCP callbacks; distinct from the public MCP host."), + new("Dashboard", "Reporting & Search", "Dashboards."), new("Global Search", "Reporting & Search", "Global search."), new("Assets", "Assets & Data", "Assets."), new("Resources", "Assets & Data", "Resource datasets."), + new("Instance Branding", "System & Diagnostics", "Instance-wide branding."), new("System", "System & Diagnostics", "System operations."), new("System Tickets", "System & Diagnostics", "Machine/system-only ticket contract."), new("Presence", "System & Diagnostics", "User presence."), new("Background Jobs", "System & Diagnostics", "Background job operations."), new("AI Agent Operations", "System & Diagnostics", "AI agent diagnostics."), new("Health", "System & Diagnostics", "Health checks.") + ]; + + private static readonly IReadOnlyDictionary CanonicalNames = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Local authentication"] = "Local Authentication", ["Role definitions"] = "Role Definitions", ["Tenant administration"] = "Tenant Administration", ["Tenant settings"] = "Tenant Settings", ["Ticketing"] = "Ticket Lookups", ["Captcha"] = "CAPTCHA", ["SLA"] = "SLA Policies", ["Email"] = "Email Processing", ["Workflow Ops"] = "Workflow Operations", ["AutomationRules"] = "Automation Rules", ["Branding"] = "Instance Branding", ["AI Agent Ops"] = "AI Agent Operations", ["Ops"] = "Background Jobs", ["Admin"] = "Tenant Administration", ["AiAssistant Chat"] = "AI Assistant Chat", ["AiAssistant Webhooks"] = "AI Assistant Webhooks", ["External orchestration"] = "External Orchestration", ["Orchestration provider"] = "Orchestration Provider", ["Self Service"] = "Self-Service" + }; + + public static Task TransformDocumentAsync(OpenApiDocument document, CancellationToken cancellationToken) + { + document.Info.Title = "RatelDesk API"; + document.Info.Description = "Use the Web-hosted reference at `/api/docs`. The API base URL is the server selected in Scalar. Local sign-in uses a browser cookie and CSRF protection; CLI and MCP use configured integration credentials. Organizations are application tenants; Customers are contacts. Pagination, filters, and errors are documented per operation."; + document.Tags = Tags.Select(tag => new OpenApiTag { Name = tag.Name, Description = tag.Description }).ToHashSet(); + document.Extensions ??= new Dictionary(); + document.Extensions["x-tagGroups"] = new JsonNodeExtension(new JsonArray(Tags.GroupBy(tag => tag.Group).Select(group => (JsonNode)new JsonObject + { + ["name"] = group.Key, + ["tags"] = new JsonArray(group.Select(tag => (JsonNode)tag.Name).ToArray()) + }).ToArray())); + return Task.CompletedTask; + } + + public static Task TransformOperationAsync(OpenApiOperation operation, ApiDescription description, CancellationToken cancellationToken) + { + var tag = operation.Tags?.Select(item => item.Name).FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(tag) && CanonicalNames.TryGetValue(tag, out var canonical)) + { + operation.Tags!.Clear(); + operation.Tags.Add(new OpenApiTagReference(canonical)); + } + + var metadata = description.ActionDescriptor.EndpointMetadata; + if (metadata?.OfType().Any() == true) + operation.Security = []; + return Task.CompletedTask; + } +} diff --git a/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs b/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs new file mode 100644 index 00000000..70689a00 --- /dev/null +++ b/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs @@ -0,0 +1,102 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Helpdesk.Infrastructure.Identity; +using Helpdesk.Shared.Auth; +using Helpdesk.Shared.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Helpdesk.API.Endpoints.Authentication; + +public static class IntegrationCredentialEndpoints +{ + private const int DefaultLifetimeDays = 30; + private const int MaximumLifetimeDays = 90; + + public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/integration-credentials") + .WithTags("Integration Credentials") + .RequireAuthorization(); + + group.MapGet("/", async (ClaimsPrincipal principal, RatelDeskIdentityDbContext identityDb, CancellationToken ct) => + { + var ownerId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(ownerId)) return Results.Unauthorized(); + var credentials = await identityDb.IntegrationCredentials.AsNoTracking() + .Where(credential => credential.OwnerUserId == ownerId) + .OrderByDescending(credential => credential.CreatedAtUtc) + .Select(credential => new IntegrationCredentialMetadata( + credential.Id, credential.Name, credential.Prefix, credential.Purpose, + credential.OrganizationId, credential.Permissions.Split(' ', StringSplitOptions.RemoveEmptyEntries), + credential.ExpiresAtUtc, credential.CreatedAtUtc, credential.LastUsedAtUtc, credential.RevokedAtUtc)) + .ToListAsync(ct); + return Results.Ok(credentials); + }).WithSummary("List integration credentials"); + + group.MapPost("/", async ( + [FromBody] CreateIntegrationCredentialRequest request, + ClaimsPrincipal principal, + ICurrentUserAccessService accessService, + RatelDeskIdentityDbContext identityDb, + CancellationToken ct) => + { + var ownerId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(ownerId) || !principal.HasClaim("auth_mode", "local")) return Results.Forbid(); + if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 128) return Results.ValidationProblem(new Dictionary { ["name"] = ["A credential name up to 128 characters is required."] }); + if (request.Purpose is not ("api" or "mcp")) return Results.ValidationProblem(new Dictionary { ["purpose"] = ["Purpose must be api or mcp."] }); + if (request.Purpose == "mcp") return Results.ValidationProblem(new Dictionary { ["purpose"] = ["MCP credentials are created through the paired HTTP MCP gateway configuration flow."] }); + + var access = await accessService.ResolveAsync(principal, ct); + var requestedPermissions = request.Permissions.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (requestedPermissions.Length == 0 || requestedPermissions.Except(access.Permissions, StringComparer.OrdinalIgnoreCase).Any() || + requestedPermissions.Any(permission => !HelpdeskPermissions.AssignablePermissions.Contains(permission, StringComparer.OrdinalIgnoreCase))) + { + return Results.Forbid(); + } + if (string.IsNullOrWhiteSpace(request.OrganizationId) || !access.AllowedOrganizationIds.Contains(request.OrganizationId)) return Results.Forbid(); + + var lifetimeDays = Math.Clamp(request.LifetimeDays ?? DefaultLifetimeDays, 1, MaximumLifetimeDays); + var id = Guid.NewGuid(); + var secret = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + var prefix = $"rdk_{id:N}"[..16]; + var credential = new IntegrationCredential + { + Id = id, + OwnerUserId = ownerId, + Name = request.Name.Trim(), + Prefix = prefix, + SecretHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(secret))), + Purpose = request.Purpose, + OrganizationId = request.OrganizationId, + Permissions = string.Join(' ', requestedPermissions.Order(StringComparer.OrdinalIgnoreCase)), + CreatedAtUtc = DateTimeOffset.UtcNow, + ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(lifetimeDays) + }; + identityDb.IntegrationCredentials.Add(credential); + await identityDb.SaveChangesAsync(ct); + return Results.Created($"/api/v1/integration-credentials/{credential.Id:N}", new CreatedIntegrationCredential( + credential.Id, credential.Prefix, $"rdk_{credential.Id:N}_{secret}", credential.Purpose, + credential.OrganizationId, requestedPermissions, credential.ExpiresAtUtc)); + }).WithSummary("Create an API integration credential"); + + group.MapDelete("/{credentialId:guid}", async (Guid credentialId, ClaimsPrincipal principal, RatelDeskIdentityDbContext identityDb, CancellationToken ct) => + { + var ownerId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(ownerId)) return Results.Unauthorized(); + var credential = await identityDb.IntegrationCredentials.SingleOrDefaultAsync(candidate => candidate.Id == credentialId && candidate.OwnerUserId == ownerId, ct); + if (credential is null) return Results.NotFound(); + if (credential.RevokedAtUtc is null) + { + credential.RevokedAtUtc = DateTimeOffset.UtcNow; + await identityDb.SaveChangesAsync(ct); + } + return Results.NoContent(); + }).WithSummary("Revoke an integration credential"); + } + + public sealed record CreateIntegrationCredentialRequest(string Name, string Purpose, string OrganizationId, IReadOnlyList Permissions, int? LifetimeDays); + public sealed record IntegrationCredentialMetadata(Guid Id, string Name, string Prefix, string Purpose, string? OrganizationId, IReadOnlyList Permissions, DateTimeOffset ExpiresAtUtc, DateTimeOffset CreatedAtUtc, DateTimeOffset? LastUsedAtUtc, DateTimeOffset? RevokedAtUtc); + public sealed record CreatedIntegrationCredential(Guid Id, string Prefix, string Secret, string Purpose, string? OrganizationId, IReadOnlyList Permissions, DateTimeOffset ExpiresAtUtc); +} diff --git a/src/Helpdesk.API/Program.cs b/src/Helpdesk.API/Program.cs index 6ace2e37..28c6fdc8 100644 --- a/src/Helpdesk.API/Program.cs +++ b/src/Helpdesk.API/Program.cs @@ -1,5 +1,7 @@ using Dodo.Primitives; using FluentValidation; +using Helpdesk.API.Authentication; +using Helpdesk.API.Documentation; using Helpdesk.API.Email; using Helpdesk.API.Configuration; using Helpdesk.API.DependencyInjection; @@ -522,6 +524,8 @@ await BootstrapStartupService.ReconcileSelectedMarkerAsync( : "Azure"; var token = auth.Substring("Bearer ".Length).Trim(); + if (token.StartsWith("rdk_", StringComparison.OrdinalIgnoreCase)) + return IntegrationCredentialAuthenticationHandler.SchemeName; try { var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); @@ -559,6 +563,9 @@ await BootstrapStartupService.ReconcileSelectedMarkerAsync( return "Azure"; }; }) +.AddScheme( + IntegrationCredentialAuthenticationHandler.SchemeName, + _ => { }) .AddCookie(LocalAuthenticationOptions.Scheme, options => { options.Cookie.Name = localAuthenticationCookieName; @@ -1026,6 +1033,7 @@ await OrchestrationCallbackEndpoints.PublishRejectedAsync( { options.AddDocumentTransformer((document, context, cancellationToken) => { + RatelDeskOpenApiCatalog.TransformDocumentAsync(document, cancellationToken); document.Components ??= new OpenApiComponents(); document.Components.SecuritySchemes ??= new Dictionary(); document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme @@ -1053,6 +1061,7 @@ [new OpenApiSecuritySchemeReference("Bearer", document)] = [] options.AddOperationTransformer((operation, context, cancellationToken) => { + RatelDeskOpenApiCatalog.TransformOperationAsync(operation, context.Description, cancellationToken); operation.Security ??= new List(); if (!operation.Security.Any(requirement => requirement.Keys.Any(scheme => string.Equals(scheme.Reference?.Id, "Bearer", StringComparison.OrdinalIgnoreCase)))) @@ -1171,6 +1180,7 @@ IResult WriteDebugLog(ILoggerFactory loggerFactory) } app.MapCurrentUserAccessEndpoint(); +app.MapIntegrationCredentialEndpoints(); app.MapGet("/api/v1/setup/status", () => Results.Ok(new { state = "Ready" })) .AllowAnonymous() .WithTags("Setup"); diff --git a/src/Helpdesk.AgentClient/AgentClientConfiguration.cs b/src/Helpdesk.AgentClient/AgentClientConfiguration.cs index 0ae19af2..4087960c 100644 --- a/src/Helpdesk.AgentClient/AgentClientConfiguration.cs +++ b/src/Helpdesk.AgentClient/AgentClientConfiguration.cs @@ -20,6 +20,8 @@ public sealed record AgentClientConfiguration( string? AuthentikScope, string? AgentUserEmail) { + public string? CredentialMode { get; init; } + public string? IntegrationCredential { get; init; } public const string DefaultApiBaseUrl = "https://api.helpdesk.example.com"; public const string DefaultScope = "openid profile email"; public static AgentClientConfiguration Empty { get; } = new(null, null, null, null, null, null, null); @@ -33,19 +35,25 @@ public AgentClientConfiguration Merge(AgentClientConfiguration next) => this wit AuthentikUsername = Pick(next.AuthentikUsername, AuthentikUsername), AuthentikAppPassword = Pick(next.AuthentikAppPassword, AuthentikAppPassword), AuthentikScope = Pick(next.AuthentikScope, AuthentikScope), - AgentUserEmail = Pick(next.AgentUserEmail, AgentUserEmail) + AgentUserEmail = Pick(next.AgentUserEmail, AgentUserEmail), + CredentialMode = Pick(next.CredentialMode, CredentialMode), + IntegrationCredential = Pick(next.IntegrationCredential, IntegrationCredential) }; public ResolvedAgentClientConfiguration Resolve() { static string Required(string? value, string key) => string.IsNullOrWhiteSpace(value) ? throw new AgentClientValidationException($"{key} is required.") : value; - return new(new Uri(string.IsNullOrWhiteSpace(ApiBaseUrl) ? DefaultApiBaseUrl : ApiBaseUrl), Required(AuthentikTokenUrl, "RATELDESK_AUTHENTIK_TOKEN_URL"), Required(AuthentikClientId, "RATELDESK_AUTHENTIK_CLIENT_ID"), Required(AuthentikUsername, "RATELDESK_AUTHENTIK_USERNAME"), Required(AuthentikAppPassword, "RATELDESK_AUTHENTIK_APP_PASSWORD"), string.IsNullOrWhiteSpace(AuthentikScope) ? DefaultScope : AuthentikScope, AgentUserEmail); + var mode = string.IsNullOrWhiteSpace(CredentialMode) ? "authentik" : CredentialMode.Trim().ToLowerInvariant(); + if (mode == "integration") + return new(new Uri(string.IsNullOrWhiteSpace(ApiBaseUrl) ? DefaultApiBaseUrl : ApiBaseUrl), null, null, null, Required(IntegrationCredential, "RATELDESK_INTEGRATION_CREDENTIAL"), null, AgentUserEmail, mode, IntegrationCredential); + if (mode != "authentik") throw new AgentClientValidationException("credentialMode must be authentik or integration."); + return new(new Uri(string.IsNullOrWhiteSpace(ApiBaseUrl) ? DefaultApiBaseUrl : ApiBaseUrl), Required(AuthentikTokenUrl, "RATELDESK_AUTHENTIK_TOKEN_URL"), Required(AuthentikClientId, "RATELDESK_AUTHENTIK_CLIENT_ID"), Required(AuthentikUsername, "RATELDESK_AUTHENTIK_USERNAME"), Required(AuthentikAppPassword, "RATELDESK_AUTHENTIK_APP_PASSWORD"), string.IsNullOrWhiteSpace(AuthentikScope) ? DefaultScope : AuthentikScope, AgentUserEmail, mode, null); } private static string? Pick(string? primary, string? fallback) => string.IsNullOrWhiteSpace(primary) ? fallback : primary; } -public sealed record ResolvedAgentClientConfiguration(Uri ApiBaseUrl, string AuthentikTokenUrl, string AuthentikClientId, string AuthentikUsername, string AuthentikAppPassword, string AuthentikScope, string? AgentUserEmail); +public sealed record ResolvedAgentClientConfiguration(Uri ApiBaseUrl, string? AuthentikTokenUrl, string? AuthentikClientId, string? AuthentikUsername, string? AuthentikAppPassword, string? AuthentikScope, string? AgentUserEmail, string CredentialMode, string? IntegrationCredential); public sealed class AgentClientConfigurationStore(string? path = null) { @@ -84,8 +92,12 @@ public static AgentClientConfiguration LoadIsolated(string? path) public static AgentClientConfiguration Load(string? path = null) { var file = new AgentClientConfigurationStore(path).Load(); - var env = new AgentClientConfiguration(Environment.GetEnvironmentVariable("RATELDESK_API_BASE_URL"), Environment.GetEnvironmentVariable("RATELDESK_AUTHENTIK_TOKEN_URL"), Environment.GetEnvironmentVariable("RATELDESK_AUTHENTIK_CLIENT_ID"), Environment.GetEnvironmentVariable("RATELDESK_AUTHENTIK_USERNAME"), Environment.GetEnvironmentVariable("RATELDESK_AUTHENTIK_APP_PASSWORD"), Environment.GetEnvironmentVariable("RATELDESK_AUTHENTIK_SCOPE"), Environment.GetEnvironmentVariable("RATELDESK_AGENT_USER_EMAIL")); + var env = new AgentClientConfiguration(Environment.GetEnvironmentVariable("RATELDESK_API_BASE_URL"), Environment.GetEnvironmentVariable("RATELDESK_AUTHENTIK_TOKEN_URL"), Environment.GetEnvironmentVariable("RATELDESK_AUTHENTIK_CLIENT_ID"), Environment.GetEnvironmentVariable("RATELDESK_AUTHENTIK_USERNAME"), Environment.GetEnvironmentVariable("RATELDESK_AUTHENTIK_APP_PASSWORD"), Environment.GetEnvironmentVariable("RATELDESK_AUTHENTIK_SCOPE"), Environment.GetEnvironmentVariable("RATELDESK_AGENT_USER_EMAIL")) + { + CredentialMode = Environment.GetEnvironmentVariable("RATELDESK_CREDENTIAL_MODE"), + IntegrationCredential = Environment.GetEnvironmentVariable("RATELDESK_INTEGRATION_CREDENTIAL") + }; return file.Merge(env); } - public static object Redact(AgentClientConfiguration config) => new { config.ApiBaseUrl, config.AuthentikTokenUrl, config.AuthentikClientId, config.AuthentikUsername, authentikAppPassword = string.IsNullOrWhiteSpace(config.AuthentikAppPassword) ? null : "***REDACTED***", config.AuthentikScope, config.AgentUserEmail }; + public static object Redact(AgentClientConfiguration config) => new { config.ApiBaseUrl, config.CredentialMode, config.AuthentikTokenUrl, config.AuthentikClientId, config.AuthentikUsername, authentikAppPassword = string.IsNullOrWhiteSpace(config.AuthentikAppPassword) ? null : "***REDACTED***", integrationCredential = string.IsNullOrWhiteSpace(config.IntegrationCredential) ? null : "***REDACTED***", config.AuthentikScope, config.AgentUserEmail }; } diff --git a/src/Helpdesk.AgentClient/HelpdeskAgentClient.cs b/src/Helpdesk.AgentClient/HelpdeskAgentClient.cs index c062f9e0..d051fa03 100644 --- a/src/Helpdesk.AgentClient/HelpdeskAgentClient.cs +++ b/src/Helpdesk.AgentClient/HelpdeskAgentClient.cs @@ -98,7 +98,7 @@ public async Task GetHealthAsync(CancellationToken cancellationToken { ("live", "/health/live", false), ("ready", "/health/ready", false), - ("auth", "/api/v1/auth/ai-agent/status", true) + ("auth", string.Equals(Configuration.Resolve().CredentialMode, "integration", StringComparison.Ordinal) ? "/api/v1/auth/me" : "/api/v1/auth/ai-agent/status", true) }) { try @@ -138,6 +138,9 @@ public async Task GetHealthAsync(CancellationToken cancellationToken public async Task GetAccessTokenAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + var configuration = Configuration.Resolve(); + if (string.Equals(configuration.CredentialMode, "integration", StringComparison.Ordinal)) + return configuration.IntegrationCredential!; var cached = Volatile.Read(ref _tokenCache); if (HasUsableToken(cached)) return cached!.Value; @@ -189,16 +192,18 @@ private async Task CompleteAccessTokenRefreshAsync(TaskCompletionSource RefreshAccessTokenAsync(CancellationToken cancellationToken) { var configuration = Configuration.Resolve(); + if (!string.Equals(configuration.CredentialMode, "authentik", StringComparison.Ordinal)) + throw new AgentClientValidationException("Only the Authentik credential provider can mint an access token."); using var client = CreateAuthClient(); using var request = new HttpRequestMessage(HttpMethod.Post, configuration.AuthentikTokenUrl) { Content = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "client_credentials", - ["client_id"] = configuration.AuthentikClientId, - ["username"] = configuration.AuthentikUsername, - ["password"] = configuration.AuthentikAppPassword, - ["scope"] = configuration.AuthentikScope + ["client_id"] = configuration.AuthentikClientId!, + ["username"] = configuration.AuthentikUsername!, + ["password"] = configuration.AuthentikAppPassword!, + ["scope"] = configuration.AuthentikScope! }) }; using var requestCancellation = CreateRequestCancellation(cancellationToken, AuthRequestTimeout); diff --git a/src/Helpdesk.Cli/CliConfig.cs b/src/Helpdesk.Cli/CliConfig.cs index 56c4f750..a32e29ac 100644 --- a/src/Helpdesk.Cli/CliConfig.cs +++ b/src/Helpdesk.Cli/CliConfig.cs @@ -10,6 +10,8 @@ internal sealed record CliConfig( string? AuthentikScope, string? AgentUserEmail) { + public string? CredentialMode { get; init; } + public string? IntegrationCredential { get; init; } public const string DefaultApiBaseUrl = "https://api.helpdesk.example.com"; public const string DefaultScope = "openid profile email"; public const string DefaultAgentUserEmail = "agent@example.com"; @@ -30,7 +32,9 @@ public CliConfig Merge(CliConfig next) => this with AuthentikUsername = Pick(next.AuthentikUsername, AuthentikUsername), AuthentikAppPassword = Pick(next.AuthentikAppPassword, AuthentikAppPassword), AuthentikScope = Pick(next.AuthentikScope, AuthentikScope), - AgentUserEmail = Pick(next.AgentUserEmail, AgentUserEmail) + AgentUserEmail = Pick(next.AgentUserEmail, AgentUserEmail), + CredentialMode = Pick(next.CredentialMode, CredentialMode), + IntegrationCredential = Pick(next.IntegrationCredential, IntegrationCredential) }; public ResolvedCliConfig Resolve() @@ -38,14 +42,11 @@ public ResolvedCliConfig Resolve() static string Require(string? value, string name) => string.IsNullOrWhiteSpace(value) ? throw new CliValidationException($"{name} is required.") : value; - return new ResolvedCliConfig( - new Uri(string.IsNullOrWhiteSpace(ApiBaseUrl) ? DefaultApiBaseUrl : ApiBaseUrl, UriKind.Absolute), - Require(AuthentikTokenUrl, "RATELDESK_AUTHENTIK_TOKEN_URL"), - Require(AuthentikClientId, "RATELDESK_AUTHENTIK_CLIENT_ID"), - Require(AuthentikUsername, "RATELDESK_AUTHENTIK_USERNAME"), - Require(AuthentikAppPassword, "RATELDESK_AUTHENTIK_APP_PASSWORD"), - string.IsNullOrWhiteSpace(AuthentikScope) ? DefaultScope : AuthentikScope, - string.IsNullOrWhiteSpace(AgentUserEmail) ? DefaultAgentUserEmail : AgentUserEmail); + var mode = string.IsNullOrWhiteSpace(CredentialMode) ? "authentik" : CredentialMode.Trim().ToLowerInvariant(); + if (mode == "integration") + return new ResolvedCliConfig(new Uri(string.IsNullOrWhiteSpace(ApiBaseUrl) ? DefaultApiBaseUrl : ApiBaseUrl, UriKind.Absolute), null, null, null, null, null, AgentUserEmail, mode, Require(IntegrationCredential, "RATELDESK_INTEGRATION_CREDENTIAL")); + if (mode != "authentik") throw new CliValidationException("credentialMode must be authentik or integration."); + return new ResolvedCliConfig(new Uri(string.IsNullOrWhiteSpace(ApiBaseUrl) ? DefaultApiBaseUrl : ApiBaseUrl, UriKind.Absolute), Require(AuthentikTokenUrl, "RATELDESK_AUTHENTIK_TOKEN_URL"), Require(AuthentikClientId, "RATELDESK_AUTHENTIK_CLIENT_ID"), Require(AuthentikUsername, "RATELDESK_AUTHENTIK_USERNAME"), Require(AuthentikAppPassword, "RATELDESK_AUTHENTIK_APP_PASSWORD"), string.IsNullOrWhiteSpace(AuthentikScope) ? DefaultScope : AuthentikScope, string.IsNullOrWhiteSpace(AgentUserEmail) ? DefaultAgentUserEmail : AgentUserEmail, mode, null); } private static string? Pick(string? primary, string? fallback) @@ -54,9 +55,11 @@ static string Require(string? value, string name) internal sealed record ResolvedCliConfig( Uri ApiBaseUrl, - string AuthentikTokenUrl, - string AuthentikClientId, - string AuthentikUsername, - string AuthentikAppPassword, - string AuthentikScope, - string AgentUserEmail); + string? AuthentikTokenUrl, + string? AuthentikClientId, + string? AuthentikUsername, + string? AuthentikAppPassword, + string? AuthentikScope, + string? AgentUserEmail, + string CredentialMode, + string? IntegrationCredential); diff --git a/src/Helpdesk.Cli/CliRuntime.cs b/src/Helpdesk.Cli/CliRuntime.cs index e3a9dadb..a71cb4a7 100644 --- a/src/Helpdesk.Cli/CliRuntime.cs +++ b/src/Helpdesk.Cli/CliRuntime.cs @@ -25,10 +25,14 @@ public HttpClient CreateHttpClient(Uri baseAddress) public async Task GetAccessTokenAsync(ResolvedCliConfig config, CancellationToken ct = default) { - var key = $"{config.ApiBaseUrl}|{config.AuthentikTokenUrl}|{config.AuthentikClientId}|{config.AuthentikUsername}|{config.AuthentikScope}"; + var key = $"{config.ApiBaseUrl}|{config.CredentialMode}|{config.AuthentikTokenUrl}|{config.AuthentikClientId}|{config.AuthentikUsername}|{config.AuthentikScope}"; if (!_agentClients.TryGetValue(key, out var agentClient)) { - agentClient = new HelpdeskAgentClient(new AgentClientConfiguration(config.ApiBaseUrl.ToString(), config.AuthentikTokenUrl, config.AuthentikClientId, config.AuthentikUsername, config.AuthentikAppPassword, config.AuthentikScope, config.AgentUserEmail), _handlerFactory); + agentClient = new HelpdeskAgentClient(new AgentClientConfiguration(config.ApiBaseUrl.ToString(), config.AuthentikTokenUrl, config.AuthentikClientId, config.AuthentikUsername, config.AuthentikAppPassword, config.AuthentikScope, config.AgentUserEmail) + { + CredentialMode = config.CredentialMode, + IntegrationCredential = config.IntegrationCredential + }, _handlerFactory); _agentClients[key] = agentClient; } try { return await agentClient.GetAccessTokenAsync(ct).ConfigureAwait(false); } diff --git a/src/Helpdesk.Cli/Helpdesk.Cli.csproj b/src/Helpdesk.Cli/Helpdesk.Cli.csproj index 7a6172a6..046b4589 100644 --- a/src/Helpdesk.Cli/Helpdesk.Cli.csproj +++ b/src/Helpdesk.Cli/Helpdesk.Cli.csproj @@ -1,6 +1,7 @@ Exe + rateldesk net10.0 enable enable diff --git a/src/Helpdesk.Cli/HelpdeskCli.Auth.cs b/src/Helpdesk.Cli/HelpdeskCli.Auth.cs index 2c32e9bf..7a4ab7a3 100644 --- a/src/Helpdesk.Cli/HelpdeskCli.Auth.cs +++ b/src/Helpdesk.Cli/HelpdeskCli.Auth.cs @@ -16,7 +16,7 @@ internal static partial class HelpdeskCli { private static Command BuildAuthCommand(CliRuntime runtime, GlobalOptions globals) { - var auth = new Command("auth", "Configure and verify Authentik AI-agent authentication."); + var auth = new Command("auth", "Configure and verify Authentik or integration-credential authentication."); var configure = new Command("configure", "Persist local CLI authentication settings."); configure.SetHandler(async ctx => @@ -28,8 +28,13 @@ private static Command BuildAuthCommand(CliRuntime runtime, GlobalOptions global }); auth.AddCommand(configure); - var status = new Command("status", "Call the Helpdesk API AI-agent status endpoint."); - status.SetHandler(ctx => SendAsync(runtime, globals, ctx, HttpMethod.Get, "/api/v1/auth/ai-agent/status")); + var status = new Command("status", "Call the current credential's Helpdesk API status endpoint."); + status.SetHandler(async ctx => + { + var resolved = LoadConfig(ctx, globals).Resolved!; + var response = await SendRawAsync(runtime, resolved, HttpMethod.Get, resolved.CredentialMode == "integration" ? "/api/v1/auth/me" : "/api/v1/auth/ai-agent/status", authenticated: true, null, ctx.GetCancellationToken()).ConfigureAwait(false); + await WriteResponseBodyAsync(runtime, response.Body, response.StatusCode, ctx, globals).ConfigureAwait(false); + }); auth.AddCommand(status); var token = new Command("token", "Mint and print an Authentik AI-agent bearer token."); @@ -101,9 +106,10 @@ private static Command BuildHealthCommand(CliRuntime runtime, GlobalOptions glob command.SetHandler(async ctx => { var loaded = LoadConfig(ctx, globals); + var authPath = loaded.Resolved!.CredentialMode == "integration" ? "/api/v1/auth/me" : "/api/v1/auth/ai-agent/status"; var live = await SendRawAsync(runtime, loaded.Resolved!, HttpMethod.Get, "/health/live", authenticated: false, null, ctx.GetCancellationToken()).ConfigureAwait(false); var ready = await SendRawAsync(runtime, loaded.Resolved!, HttpMethod.Get, "/health/ready", authenticated: false, null, ctx.GetCancellationToken()).ConfigureAwait(false); - var auth = await SendRawAsync(runtime, loaded.Resolved!, HttpMethod.Get, "/api/v1/auth/ai-agent/status", authenticated: true, null, ctx.GetCancellationToken()).ConfigureAwait(false); + var auth = await SendRawAsync(runtime, loaded.Resolved!, HttpMethod.Get, authPath, authenticated: true, null, ctx.GetCancellationToken()).ConfigureAwait(false); await WriteJsonAsync(runtime, new[] { live.ToHealth("live"), ready.ToHealth("ready"), auth.ToHealth("auth") }, ctx, globals).ConfigureAwait(false); }); return command; diff --git a/src/Helpdesk.Cli/HelpdeskCli.Transport.cs b/src/Helpdesk.Cli/HelpdeskCli.Transport.cs index 64f7838f..72d5facd 100644 --- a/src/Helpdesk.Cli/HelpdeskCli.Transport.cs +++ b/src/Helpdesk.Cli/HelpdeskCli.Transport.cs @@ -95,7 +95,11 @@ private static LoadedConfig LoadConfig(InvocationContext ctx, GlobalOptions glob Environment.GetEnvironmentVariable("RATELDESK_AUTHENTIK_USERNAME"), Environment.GetEnvironmentVariable("RATELDESK_AUTHENTIK_APP_PASSWORD"), Environment.GetEnvironmentVariable("RATELDESK_AUTHENTIK_SCOPE"), - Environment.GetEnvironmentVariable("RATELDESK_AGENT_USER_EMAIL")); + Environment.GetEnvironmentVariable("RATELDESK_AGENT_USER_EMAIL")) + { + CredentialMode = Environment.GetEnvironmentVariable("RATELDESK_CREDENTIAL_MODE"), + IntegrationCredential = Environment.GetEnvironmentVariable("RATELDESK_INTEGRATION_CREDENTIAL") + }; var overrides = new CliConfig( ctx.ParseResult.GetValueForOption(globals.ApiBaseUrl), ctx.ParseResult.GetValueForOption(globals.TokenUrl), @@ -717,6 +721,8 @@ private static async Task ResolveAgentUserIdAsync(CliRuntime runtime, Gl var email = string.IsNullOrWhiteSpace(explicitEmail) ? loaded.Resolved!.AgentUserEmail : explicitEmail; + if (string.IsNullOrWhiteSpace(email)) + throw new CliValidationException("An agent user email is required for assign-self."); var response = await SendRawAsync(runtime, loaded.Resolved!, HttpMethod.Get, "/api/v1/users/by-email/" + Escape(email), authenticated: true, null, ctx.GetCancellationToken()).ConfigureAwait(false); if (!response.IsSuccess) { @@ -759,7 +765,8 @@ string s when string.IsNullOrWhiteSpace(s) => null, private static CliConfig Redact(CliConfig config) => config with { - AuthentikAppPassword = string.IsNullOrWhiteSpace(config.AuthentikAppPassword) ? null : "***" + AuthentikAppPassword = string.IsNullOrWhiteSpace(config.AuthentikAppPassword) ? null : "***", + IntegrationCredential = string.IsNullOrWhiteSpace(config.IntegrationCredential) ? null : "***" }; private static string? GetConfigValue(CliConfig config, string key, bool redact) => NormalizeConfigKey(key) switch @@ -769,6 +776,8 @@ private static CliConfig Redact(CliConfig config) => config with "authentikClientId" => config.AuthentikClientId, "authentikUsername" => config.AuthentikUsername, "authentikAppPassword" => redact && !string.IsNullOrWhiteSpace(config.AuthentikAppPassword) ? "***" : config.AuthentikAppPassword, + "credentialMode" => config.CredentialMode, + "integrationCredential" => redact && !string.IsNullOrWhiteSpace(config.IntegrationCredential) ? "***" : config.IntegrationCredential, "authentikScope" => config.AuthentikScope, "agentUserEmail" => config.AgentUserEmail, _ => throw new CliValidationException($"Unknown config key '{key}'.") @@ -781,6 +790,8 @@ private static CliConfig Redact(CliConfig config) => config with "authentikClientId" => config with { AuthentikClientId = value }, "authentikUsername" => config with { AuthentikUsername = value }, "authentikAppPassword" => config with { AuthentikAppPassword = value }, + "credentialMode" => config with { CredentialMode = value }, + "integrationCredential" => config with { IntegrationCredential = value }, "authentikScope" => config with { AuthentikScope = value }, "agentUserEmail" => config with { AgentUserEmail = value }, _ => throw new CliValidationException($"Unknown config key '{key}'.") diff --git a/src/Helpdesk.Cli/Program.cs b/src/Helpdesk.Cli/Program.cs index fc57849d..627a8e43 100644 --- a/src/Helpdesk.Cli/Program.cs +++ b/src/Helpdesk.Cli/Program.cs @@ -2,5 +2,15 @@ namespace Helpdesk.Cli; internal static class Program { - private static Task Main(string[] args) => HelpdeskCli.RunAsync(args); + private static Task Main(string[] args) + { + if (args is ["--version"]) + { + Console.Out.WriteLine(BuildIdentity()); + return Task.FromResult(0); + } + return HelpdeskCli.RunAsync(args); + } + + private static string BuildIdentity() => $"rateldesk {typeof(Program).Assembly.GetName().Version} ({typeof(Program).Assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false).OfType().SingleOrDefault()?.InformationalVersion ?? "unknown"})"; } diff --git a/src/Helpdesk.Infrastructure/Auth/Rbac/CurrentUserAccessService.cs b/src/Helpdesk.Infrastructure/Auth/Rbac/CurrentUserAccessService.cs index 7199fc3f..e477d193 100644 --- a/src/Helpdesk.Infrastructure/Auth/Rbac/CurrentUserAccessService.cs +++ b/src/Helpdesk.Infrastructure/Auth/Rbac/CurrentUserAccessService.cs @@ -221,7 +221,7 @@ persistedRole.OwnerOrganizationId is not null && scopedPermissionGrants.Add(new ScopedPermissionGrant(permission, organizationId)); } - return new CurrentUserAccessProfile( + var profile = new CurrentUserAccessProfile( IsAuthenticated: true, Name: user.Identity?.Name ?? FirstClaim(user, "name", "preferred_username") ?? email, Email: email, @@ -237,6 +237,8 @@ persistedRole.OwnerOrganizationId is not null && UsesScopedPermissions = true, ScopedPermissionGrants = scopedPermissionGrants }; + + return ConstrainIntegrationCredential(profile, user); } private async Task FindCustomerAuthLinkAsync( @@ -271,7 +273,43 @@ persistedRole.OwnerOrganizationId is not null && } private static bool IsLocalAccount(ClaimsPrincipal user) => - string.Equals(user.FindFirstValue("auth_mode"), "local", StringComparison.OrdinalIgnoreCase); + user.FindFirstValue("auth_mode") is "local" or "integration"; + + private static CurrentUserAccessProfile ConstrainIntegrationCredential(CurrentUserAccessProfile profile, ClaimsPrincipal user) + { + if (!string.Equals(user.FindFirstValue("auth_mode"), "integration", StringComparison.OrdinalIgnoreCase)) + return profile; + + var requestedPermissions = user.FindAll("integration_permission") + .Select(claim => claim.Value) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var requestedOrganizationId = user.FindFirstValue("integration_organization_id"); + if (requestedPermissions.Count == 0 || string.IsNullOrWhiteSpace(requestedOrganizationId)) + return Empty(true); + + var effectivePermissions = profile.Permissions + .Where(requestedPermissions.Contains) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var effectiveOrganizations = profile.AllowedOrganizationIds + .Where(id => string.Equals(id, requestedOrganizationId, StringComparison.OrdinalIgnoreCase)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var effectiveGrants = profile.ScopedPermissionGrants + .Where(grant => requestedPermissions.Contains(grant.Permission) && effectiveOrganizations.Contains(grant.OrganizationId)) + .ToHashSet(); + + return profile with + { + IsHelpdeskAdmin = false, + RoleBundles = effectivePermissions, + Permissions = effectivePermissions, + AllowedOrganizationIds = effectiveOrganizations, + ManagedOrganizationIds = profile.ManagedOrganizationIds + .Where(effectiveOrganizations.Contains) + .ToHashSet(StringComparer.OrdinalIgnoreCase), + UsesScopedPermissions = true, + ScopedPermissionGrants = effectiveGrants + }; + } private static void AddDirectPermissionClaims(HashSet groups, HashSet permissions) { diff --git a/src/Helpdesk.Infrastructure/Identity/IntegrationCredential.cs b/src/Helpdesk.Infrastructure/Identity/IntegrationCredential.cs new file mode 100644 index 00000000..b263d6aa --- /dev/null +++ b/src/Helpdesk.Infrastructure/Identity/IntegrationCredential.cs @@ -0,0 +1,21 @@ +namespace Helpdesk.Infrastructure.Identity; + +/// +/// A revocable, opaque credential owned by one application account. Only a +/// SHA-256 verifier is persisted; the displayed secret is never recoverable. +/// +public sealed class IntegrationCredential +{ + public Guid Id { get; set; } + public string OwnerUserId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Prefix { get; set; } = string.Empty; + public string SecretHash { get; set; } = string.Empty; + public string Purpose { get; set; } = string.Empty; + public string? OrganizationId { get; set; } + public string Permissions { get; set; } = string.Empty; + public DateTimeOffset ExpiresAtUtc { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } + public DateTimeOffset? LastUsedAtUtc { get; set; } + public DateTimeOffset? RevokedAtUtc { get; set; } +} diff --git a/src/Helpdesk.Infrastructure/Identity/Migrations/20260917091844_AddIntegrationCredentials.Designer.cs b/src/Helpdesk.Infrastructure/Identity/Migrations/20260917091844_AddIntegrationCredentials.Designer.cs new file mode 100644 index 00000000..29771c3f --- /dev/null +++ b/src/Helpdesk.Infrastructure/Identity/Migrations/20260917091844_AddIntegrationCredentials.Designer.cs @@ -0,0 +1,363 @@ +// +using System; +using Helpdesk.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Helpdesk.Infrastructure.Identity.Migrations +{ + [DbContext(typeof(RatelDeskIdentityDbContext))] + [Migration("20260917091844_AddIntegrationCredentials")] + partial class AddIntegrationCredentials + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Helpdesk.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("AuthorizationRevision") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisabledAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsInstanceAdministrator") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Helpdesk.Infrastructure.Identity.IntegrationCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("OrganizationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Permissions") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Purpose", "ExpiresAtUtc", "RevokedAtUtc"); + + b.ToTable("IntegrationCredentials", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Helpdesk.Infrastructure/Identity/Migrations/20260917091844_AddIntegrationCredentials.cs b/src/Helpdesk.Infrastructure/Identity/Migrations/20260917091844_AddIntegrationCredentials.cs new file mode 100644 index 00000000..3f8e914f --- /dev/null +++ b/src/Helpdesk.Infrastructure/Identity/Migrations/20260917091844_AddIntegrationCredentials.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Helpdesk.Infrastructure.Identity.Migrations +{ + /// + public partial class AddIntegrationCredentials : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "IntegrationCredentials", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OwnerUserId = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Prefix = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + SecretHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Purpose = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + OrganizationId = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + Permissions = table.Column(type: "character varying(4096)", maxLength: 4096, nullable: false), + ExpiresAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + LastUsedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + RevokedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_IntegrationCredentials", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_IntegrationCredentials_OwnerUserId", + table: "IntegrationCredentials", + column: "OwnerUserId"); + + migrationBuilder.CreateIndex( + name: "IX_IntegrationCredentials_Purpose_ExpiresAtUtc_RevokedAtUtc", + table: "IntegrationCredentials", + columns: new[] { "Purpose", "ExpiresAtUtc", "RevokedAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "IntegrationCredentials"); + } + } +} diff --git a/src/Helpdesk.Infrastructure/Identity/Migrations/RatelDeskIdentityDbContextModelSnapshot.cs b/src/Helpdesk.Infrastructure/Identity/Migrations/RatelDeskIdentityDbContextModelSnapshot.cs index e54c0c18..dff39734 100644 --- a/src/Helpdesk.Infrastructure/Identity/Migrations/RatelDeskIdentityDbContextModelSnapshot.cs +++ b/src/Helpdesk.Infrastructure/Identity/Migrations/RatelDeskIdentityDbContextModelSnapshot.cs @@ -111,6 +111,67 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("Helpdesk.Infrastructure.Identity.IntegrationCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("OrganizationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Permissions") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Purpose", "ExpiresAtUtc", "RevokedAtUtc"); + + b.ToTable("IntegrationCredentials", (string)null); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { b.Property("Id") diff --git a/src/Helpdesk.Infrastructure/Identity/RatelDeskIdentityDbContext.cs b/src/Helpdesk.Infrastructure/Identity/RatelDeskIdentityDbContext.cs index 44e711f5..b157325e 100644 --- a/src/Helpdesk.Infrastructure/Identity/RatelDeskIdentityDbContext.cs +++ b/src/Helpdesk.Infrastructure/Identity/RatelDeskIdentityDbContext.cs @@ -7,6 +7,8 @@ namespace Helpdesk.Infrastructure.Identity; public sealed class RatelDeskIdentityDbContext(DbContextOptions options) : IdentityDbContext(options) { + public DbSet IntegrationCredentials => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); @@ -19,5 +21,20 @@ protected override void OnModelCreating(ModelBuilder builder) entity.Property(user => user.AuthorizationRevision).HasDefaultValue(0L); entity.HasIndex(user => user.IsEnabled); }); + + builder.Entity(entity => + { + entity.ToTable("IntegrationCredentials"); + entity.HasKey(credential => credential.Id); + entity.Property(credential => credential.OwnerUserId).HasMaxLength(450).IsRequired(); + entity.Property(credential => credential.Name).HasMaxLength(128).IsRequired(); + entity.Property(credential => credential.Prefix).HasMaxLength(32).IsRequired(); + entity.Property(credential => credential.SecretHash).HasMaxLength(128).IsRequired(); + entity.Property(credential => credential.Purpose).HasMaxLength(16).IsRequired(); + entity.Property(credential => credential.OrganizationId).HasMaxLength(128); + entity.Property(credential => credential.Permissions).HasMaxLength(4096).IsRequired(); + entity.HasIndex(credential => credential.OwnerUserId); + entity.HasIndex(credential => new { credential.Purpose, credential.ExpiresAtUtc, credential.RevokedAtUtc }); + }); } } diff --git a/src/Helpdesk.Mcp.Core/Configuration/HelpdeskMcpTarget.cs b/src/Helpdesk.Mcp.Core/Configuration/HelpdeskMcpTarget.cs index 9fd42a5d..5a81b1e3 100644 --- a/src/Helpdesk.Mcp.Core/Configuration/HelpdeskMcpTarget.cs +++ b/src/Helpdesk.Mcp.Core/Configuration/HelpdeskMcpTarget.cs @@ -1,10 +1,11 @@ using Helpdesk.AgentClient; using Helpdesk.Mcp.Tools; +using System.Text.RegularExpressions; using System.Text.Json.Serialization; namespace Helpdesk.Mcp.Configuration; -public sealed record HelpdeskMcpTarget(string Instance, Uri ApiBaseUrl) +public sealed partial record HelpdeskMcpTarget(string Instance, Uri ApiBaseUrl) { public const string InstanceEnvironmentVariable = "RATELDESK_MCP_INSTANCE"; public const string ConfigurationEnvironmentVariable = "RATELDESK_MCP_CONFIG"; @@ -27,8 +28,8 @@ public static HelpdeskMcpTarget Resolve( throw new AgentClientValidationException($"{ConfigurationEnvironmentVariable} is required for isolated MCP configuration."); var instance = (string.IsNullOrWhiteSpace(instanceOverride) ? Environment.GetEnvironmentVariable(InstanceEnvironmentVariable) : instanceOverride)?.Trim().ToLowerInvariant(); - if (instance is not ("dev" or "prod")) - throw new AgentClientValidationException($"{InstanceEnvironmentVariable} must be dev or prod."); + if (string.IsNullOrWhiteSpace(instance) || !InstanceLabel().IsMatch(instance)) + throw new AgentClientValidationException($"{InstanceEnvironmentVariable} must be a lowercase instance label containing letters, digits, and hyphens."); var expectedEndpointVariable = $"RATELDESK_MCP_{instance.ToUpperInvariant()}_API_BASE_URL"; var expectedEndpoint = ParseUri(string.IsNullOrWhiteSpace(expectedEndpointOverride) ? Environment.GetEnvironmentVariable(expectedEndpointVariable) : expectedEndpointOverride, expectedEndpointVariable); @@ -47,8 +48,11 @@ public bool MatchesApiBaseUrl(string? value) private static Uri ParseUri(string? value, string name) { - if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || !IsHttpUri(uri)) - throw new AgentClientValidationException($"{name} must be an absolute http or https URL."); + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || !IsHttpUri(uri) || + !string.IsNullOrEmpty(uri.UserInfo) || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment)) + { + throw new AgentClientValidationException($"{name} must be an absolute http or https URL without credentials, query, or fragment."); + } return uri; } @@ -71,6 +75,9 @@ private static string Normalize(Uri uri) return builder.Uri.AbsoluteUri.TrimEnd('/'); } + + [GeneratedRegex("^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")] + private static partial Regex InstanceLabel(); } public sealed record HelpdeskMcpTargetInfo( diff --git a/src/Helpdesk.Mcp/Helpdesk.Mcp.csproj b/src/Helpdesk.Mcp/Helpdesk.Mcp.csproj index deb96051..2f11752a 100644 --- a/src/Helpdesk.Mcp/Helpdesk.Mcp.csproj +++ b/src/Helpdesk.Mcp/Helpdesk.Mcp.csproj @@ -1,6 +1,7 @@ Exe + rateldesk-mcp net10.0 enable enable diff --git a/src/Helpdesk.Mcp/Program.cs b/src/Helpdesk.Mcp/Program.cs index 5b9eb716..f67457b0 100644 --- a/src/Helpdesk.Mcp/Program.cs +++ b/src/Helpdesk.Mcp/Program.cs @@ -14,6 +14,16 @@ public static class Program { public static async Task Main(string[] args) { + if (args is ["--help"] or ["-h"]) + { + await Console.Out.WriteLineAsync("rateldesk-mcp - RatelDesk stdio MCP server\n\nConfigure RATELDESK_MCP_CONFIG and RATELDESK_MCP_INSTANCE before normal server mode. Logs are written to stderr; stdout is reserved for MCP protocol traffic."); + return 0; + } + if (args is ["--version"]) + { + await Console.Out.WriteLineAsync($"rateldesk-mcp {typeof(Program).Assembly.GetName().Version} ({typeof(Program).Assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false).OfType().SingleOrDefault()?.InformationalVersion ?? "unknown"})"); + return 0; + } try { var configurationPath = Environment.GetEnvironmentVariable(HelpdeskMcpTarget.ConfigurationEnvironmentVariable); diff --git a/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs b/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs index c3cc61b2..7d9f7fe7 100644 --- a/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs +++ b/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Text.Json; using Helpdesk.API; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -42,6 +43,10 @@ public async Task OpenApi_V1_Json_IsServed() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Contains("\"openapi\"", content, StringComparison.OrdinalIgnoreCase); Assert.Contains("/api/v1/system/version", content, StringComparison.Ordinal); + using var document = JsonDocument.Parse(content); + Assert.Equal("RatelDesk API", document.RootElement.GetProperty("info").GetProperty("title").GetString()); + Assert.True(document.RootElement.TryGetProperty("x-tagGroups", out var groups)); + Assert.Contains(groups.EnumerateArray(), group => group.GetProperty("name").GetString() == "Ticketing"); } } diff --git a/tools/release/package-assets.sh b/tools/release/package-assets.sh new file mode 100755 index 00000000..a3b83943 --- /dev/null +++ b/tools/release/package-assets.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Builds the release payload used by tag workflows and local release rehearsal. +# It deliberately never tags, pushes, or publishes anything. +if [[ $# -ne 3 ]]; then + echo "usage: $0 " >&2 + exit 64 +fi + +version="$1" +source_revision="$2" +output_directory="$3" +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +staging_directory="$output_directory/staging" +mkdir -p "$staging_directory" "$output_directory" + +declare -a rids=(linux-x64 linux-arm64 win-x64 osx-x64 osx-arm64) + +package_tool() { + local project="$1" tool="$2" archive_prefix="$3" rid="$4" + local publish_directory="$staging_directory/${tool}-${rid}" + local archive_base="${archive_prefix}-${version}-${rid}" + local package_directory="$staging_directory/$archive_base" + rm -rf "$publish_directory" "$package_directory" + dotnet restore "$project" --runtime "$rid" + dotnet publish "$project" --configuration Release --runtime "$rid" --self-contained true \ + --output "$publish_directory" --no-restore \ + -p:Version="$version" -p:SourceRevisionId="$source_revision" + mkdir -p "$package_directory" + cp -a "$publish_directory/." "$package_directory/" + cp "$repository_root/LICENSE" "$package_directory/LICENSE" + cat > "$package_directory/README.txt" < SHA256SUMS) +python3 - "$output_directory" "$version" "$source_revision" <<'PY' +import hashlib, json, pathlib, sys +directory = pathlib.Path(sys.argv[1]) +hashes = {} +for line in (directory / "SHA256SUMS").read_text().splitlines(): + digest, name = line.split(maxsplit=1) + hashes[name.strip()] = digest +(directory / "release-manifest.json").write_text(json.dumps({ + "version": sys.argv[2], "sourceRevision": sys.argv[3], + "supportedRids": ["linux-x64", "linux-arm64", "win-x64", "osx-x64", "osx-arm64"], + "assets": hashes, + "containers": [ + f"ghcr.io/bostontechnologies/rateldesk-web:{sys.argv[2]}", + f"ghcr.io/bostontechnologies/rateldesk-api:{sys.argv[2]}", + f"ghcr.io/bostontechnologies/rateldesk-mcp-http:{sys.argv[2]}" + ] +}, indent=2) + "\n") +PY From 67ac3b524ec2486c98d321cb6e699d48100fd599 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 11:57:05 +0200 Subject: [PATCH 02/32] fix(ci): repair MCP release validation --- .github/workflows/pull-request-validation.yml | 1 + docker/mcp-http/Dockerfile | 9 +- ...5318_AddIntegrationCredentials.Designer.cs | 354 ++++++++++++++++++ ...0260917095318_AddIntegrationCredentials.cs | 54 +++ ...RatelDeskIdentityDbContextModelSnapshot.cs | 61 +++ tools/ci/check-public-disclosure.sh | 1 + tools/release/package-assets.sh | 4 +- 7 files changed, 479 insertions(+), 5 deletions(-) create mode 100644 src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917095318_AddIntegrationCredentials.Designer.cs create mode 100644 src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917095318_AddIntegrationCredentials.cs diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 6dd19536..398b1fe5 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -260,6 +260,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 env: + RATELDESK_VERSION: 0.0.0-ci RATELDESK_POSTGRES_CONNECTION_STRING: Host=postgres.example.test;Port=5432;Database=rateldesk;Username=rateldesk;Password=ci-placeholder;Ssl Mode=Require RATELDESK_BOOTSTRAP_ADMIN_EMAIL: admin@example.test RATELDESK_BOOTSTRAP_ADMIN_DISPLAY_NAME: Initial Administrator diff --git a/docker/mcp-http/Dockerfile b/docker/mcp-http/Dockerfile index a1c20f16..1810c8b3 100644 --- a/docker/mcp-http/Dockerfile +++ b/docker/mcp-http/Dockerfile @@ -10,9 +10,9 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD wget FROM mcr.microsoft.com/dotnet/sdk:10.0.401-alpine3.23 AS build WORKDIR /src ARG BUILD_CONFIGURATION=Release -ARG VERSION -ARG SOURCE_REVISION_ID -ARG BUILD_TIMESTAMP +ARG VERSION=0.0.0-local +ARG SOURCE_REVISION_ID=unknown +ARG BUILD_TIMESTAMP= COPY global.json ./ COPY Directory.Build.props Directory.Build.targets ./ @@ -34,7 +34,8 @@ RUN mkdir /out \ /p:UseAppHost=false \ /p:Version=$VERSION \ /p:SourceRevisionId=$SOURCE_REVISION_ID \ - /p:BuildTimestamp=$BUILD_TIMESTAMP + /p:BuildTimestamp=$BUILD_TIMESTAMP \ + /p:ContinuousIntegrationBuild=true FROM base AS final WORKDIR /app diff --git a/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917095318_AddIntegrationCredentials.Designer.cs b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917095318_AddIntegrationCredentials.Designer.cs new file mode 100644 index 00000000..540211c5 --- /dev/null +++ b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917095318_AddIntegrationCredentials.Designer.cs @@ -0,0 +1,354 @@ +// +using System; +using Helpdesk.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Helpdesk.Infrastructure.SqliteMigrations.Migrations.Identity +{ + [DbContext(typeof(RatelDeskIdentityDbContext))] + [Migration("20260917095318_AddIntegrationCredentials")] + partial class AddIntegrationCredentials + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Helpdesk.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AuthorizationRevision") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0L); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("DisabledAtUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IsInstanceAdministrator") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Helpdesk.Infrastructure.Identity.IntegrationCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Permissions") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Purpose", "ExpiresAtUtc", "RevokedAtUtc"); + + b.ToTable("IntegrationCredentials", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917095318_AddIntegrationCredentials.cs b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917095318_AddIntegrationCredentials.cs new file mode 100644 index 00000000..ef4ef3e7 --- /dev/null +++ b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917095318_AddIntegrationCredentials.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Helpdesk.Infrastructure.SqliteMigrations.Migrations.Identity +{ + /// + public partial class AddIntegrationCredentials : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "IntegrationCredentials", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + OwnerUserId = table.Column(type: "TEXT", maxLength: 450, nullable: false), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Prefix = table.Column(type: "TEXT", maxLength: 32, nullable: false), + SecretHash = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Purpose = table.Column(type: "TEXT", maxLength: 16, nullable: false), + OrganizationId = table.Column(type: "TEXT", maxLength: 128, nullable: true), + Permissions = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + ExpiresAtUtc = table.Column(type: "TEXT", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + LastUsedAtUtc = table.Column(type: "TEXT", nullable: true), + RevokedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_IntegrationCredentials", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_IntegrationCredentials_OwnerUserId", + table: "IntegrationCredentials", + column: "OwnerUserId"); + + migrationBuilder.CreateIndex( + name: "IX_IntegrationCredentials_Purpose_ExpiresAtUtc_RevokedAtUtc", + table: "IntegrationCredentials", + columns: new[] { "Purpose", "ExpiresAtUtc", "RevokedAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "IntegrationCredentials"); + } + } +} diff --git a/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/RatelDeskIdentityDbContextModelSnapshot.cs b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/RatelDeskIdentityDbContextModelSnapshot.cs index ea044f19..27c37e25 100644 --- a/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/RatelDeskIdentityDbContextModelSnapshot.cs +++ b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/RatelDeskIdentityDbContextModelSnapshot.cs @@ -106,6 +106,67 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("Helpdesk.Infrastructure.Identity.IntegrationCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Permissions") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Purpose", "ExpiresAtUtc", "RevokedAtUtc"); + + b.ToTable("IntegrationCredentials", (string)null); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { b.Property("Id") diff --git a/tools/ci/check-public-disclosure.sh b/tools/ci/check-public-disclosure.sh index 482ad905..75e4078a 100755 --- a/tools/ci/check-public-disclosure.sh +++ b/tools/ci/check-public-disclosure.sh @@ -26,6 +26,7 @@ disallowed_matches=$(rg -n -i -e "$blocked_text" \ -e 's#https://github.com/BostonTechnologies/RatelDesk##g' \ -e 's#ghcr.io/bostontechnologies/rateldesk-web##g' \ -e 's#ghcr.io/bostontechnologies/rateldesk-api##g' \ + -e 's#ghcr.io/bostontechnologies/rateldesk-mcp-http##g' \ -e 's#orgs/BostonTechnologies/packages/container##g' \ -e 's#BostonTechnologies/RatelDesk##g' \ | rg -n -i -e "$blocked_text" || true) diff --git a/tools/release/package-assets.sh b/tools/release/package-assets.sh index a3b83943..faa9739e 100755 --- a/tools/release/package-assets.sh +++ b/tools/release/package-assets.sh @@ -12,8 +12,10 @@ version="$1" source_revision="$2" output_directory="$3" repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +mkdir -p "$output_directory" +output_directory="$(cd "$output_directory" && pwd)" staging_directory="$output_directory/staging" -mkdir -p "$staging_directory" "$output_directory" +mkdir -p "$staging_directory" declare -a rids=(linux-x64 linux-arm64 win-x64 osx-x64 osx-arm64) From 829e11c527adb18c9ceb6365fe0511208ac0fd27 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 12:19:01 +0200 Subject: [PATCH 03/32] test(mcp): align target validation expectations --- tests/Helpdesk.Tests/Mcp/HelpdeskMcpProcessTests.cs | 2 +- tests/Helpdesk.Tests/Mcp/HelpdeskMcpTargetTests.cs | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/Helpdesk.Tests/Mcp/HelpdeskMcpProcessTests.cs b/tests/Helpdesk.Tests/Mcp/HelpdeskMcpProcessTests.cs index f133ec80..bc990848 100644 --- a/tests/Helpdesk.Tests/Mcp/HelpdeskMcpProcessTests.cs +++ b/tests/Helpdesk.Tests/Mcp/HelpdeskMcpProcessTests.cs @@ -36,7 +36,7 @@ public async Task Missing_instance_fails_closed_with_stderr_only_diagnostic() Assert.Equal(78, process.ExitCode); Assert.Equal(string.Empty, await stdout); - Assert.Contains("RATELDESK_MCP_INSTANCE must be dev or prod.", await stderr, StringComparison.Ordinal); + Assert.Contains("RATELDESK_MCP_INSTANCE must be a lowercase instance label containing letters, digits, and hyphens.", await stderr, StringComparison.Ordinal); } [Fact] diff --git a/tests/Helpdesk.Tests/Mcp/HelpdeskMcpTargetTests.cs b/tests/Helpdesk.Tests/Mcp/HelpdeskMcpTargetTests.cs index 3f1eee42..dae17322 100644 --- a/tests/Helpdesk.Tests/Mcp/HelpdeskMcpTargetTests.cs +++ b/tests/Helpdesk.Tests/Mcp/HelpdeskMcpTargetTests.cs @@ -21,17 +21,16 @@ public void Resolve_accepts_matching_dev_endpoint_and_normalizes_trailing_slashe } [Theory] - [InlineData(null, "https://dev.example", "RATELDESK_MCP_INSTANCE must be dev or prod.")] - [InlineData("stage", "https://dev.example", "RATELDESK_MCP_INSTANCE must be dev or prod.")] - [InlineData("dev", null, "RATELDESK_MCP_DEV_API_BASE_URL must be an absolute http or https URL.")] + [InlineData(null, "https://dev.example", "RATELDESK_MCP_INSTANCE must be a lowercase instance label containing letters, digits, and hyphens.")] + [InlineData("stage", "https://stage.example", "RATELDESK_MCP_INSTANCE 'stage' requires")] + [InlineData("dev", null, "RATELDESK_MCP_DEV_API_BASE_URL must be an absolute http or https URL without credentials, query, or fragment.")] [InlineData("prod", "https://prod.example", "RATELDESK_MCP_INSTANCE 'prod' requires")] public void Resolve_rejects_missing_or_mismatched_target_configuration(string? instance, string? expectedEndpoint, string error) { using var environment = new EnvironmentVariables( (HelpdeskMcpTarget.InstanceEnvironmentVariable, instance), (HelpdeskMcpTarget.ConfigurationEnvironmentVariable, "/tmp/helpdesk-prod.json"), - ("RATELDESK_MCP_DEV_API_BASE_URL", expectedEndpoint), - ("RATELDESK_MCP_PROD_API_BASE_URL", expectedEndpoint)); + (instance is null ? "RATELDESK_MCP_DEV_API_BASE_URL" : $"RATELDESK_MCP_{instance.ToUpperInvariant()}_API_BASE_URL", expectedEndpoint)); var exception = Assert.Throws(() => HelpdeskMcpTarget.Resolve(Configuration("https://dev.example"), "/tmp/helpdesk-prod.json")); From c08e9d3c929410f2a8ce1a7c1c39836c8cdc525c Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 13:50:02 +0200 Subject: [PATCH 04/32] fix(credentials): order SQLite listings by UTC sort key --- .../IntegrationCredentialEndpoints.cs | 9 +- ...rationCredentialCreatedSortKey.Designer.cs | 359 +++++++++++++++++ ..._AddIntegrationCredentialCreatedSortKey.cs | 46 +++ ...RatelDeskIdentityDbContextModelSnapshot.cs | 5 + .../Identity/IntegrationCredential.cs | 5 + ...rationCredentialCreatedSortKey.Designer.cs | 368 ++++++++++++++++++ ..._AddIntegrationCredentialCreatedSortKey.cs | 44 +++ ...RatelDeskIdentityDbContextModelSnapshot.cs | 5 + .../Identity/RatelDeskIdentityDbContext.cs | 1 + .../Api/IntegrationCredentialSqliteTests.cs | 153 ++++++++ 10 files changed, 992 insertions(+), 3 deletions(-) create mode 100644 src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917114349_AddIntegrationCredentialCreatedSortKey.Designer.cs create mode 100644 src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917114349_AddIntegrationCredentialCreatedSortKey.cs create mode 100644 src/Helpdesk.Infrastructure/Identity/Migrations/20260917114328_AddIntegrationCredentialCreatedSortKey.Designer.cs create mode 100644 src/Helpdesk.Infrastructure/Identity/Migrations/20260917114328_AddIntegrationCredentialCreatedSortKey.cs create mode 100644 tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs diff --git a/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs b/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs index 70689a00..6a85f5bd 100644 --- a/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs +++ b/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs @@ -26,7 +26,8 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder if (string.IsNullOrWhiteSpace(ownerId)) return Results.Unauthorized(); var credentials = await identityDb.IntegrationCredentials.AsNoTracking() .Where(credential => credential.OwnerUserId == ownerId) - .OrderByDescending(credential => credential.CreatedAtUtc) + .OrderByDescending(credential => credential.CreatedAtUnixMilliseconds) + .ThenByDescending(credential => credential.Id) .Select(credential => new IntegrationCredentialMetadata( credential.Id, credential.Name, credential.Prefix, credential.Purpose, credential.OrganizationId, credential.Permissions.Split(' ', StringSplitOptions.RemoveEmptyEntries), @@ -61,6 +62,7 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder var id = Guid.NewGuid(); var secret = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); var prefix = $"rdk_{id:N}"[..16]; + var createdAtUtc = DateTimeOffset.UtcNow; var credential = new IntegrationCredential { Id = id, @@ -71,8 +73,9 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder Purpose = request.Purpose, OrganizationId = request.OrganizationId, Permissions = string.Join(' ', requestedPermissions.Order(StringComparer.OrdinalIgnoreCase)), - CreatedAtUtc = DateTimeOffset.UtcNow, - ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(lifetimeDays) + CreatedAtUtc = createdAtUtc, + CreatedAtUnixMilliseconds = createdAtUtc.ToUnixTimeMilliseconds(), + ExpiresAtUtc = createdAtUtc.AddDays(lifetimeDays) }; identityDb.IntegrationCredentials.Add(credential); await identityDb.SaveChangesAsync(ct); diff --git a/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917114349_AddIntegrationCredentialCreatedSortKey.Designer.cs b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917114349_AddIntegrationCredentialCreatedSortKey.Designer.cs new file mode 100644 index 00000000..0b4ddcc6 --- /dev/null +++ b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917114349_AddIntegrationCredentialCreatedSortKey.Designer.cs @@ -0,0 +1,359 @@ +// +using System; +using Helpdesk.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Helpdesk.Infrastructure.SqliteMigrations.Migrations.Identity +{ + [DbContext(typeof(RatelDeskIdentityDbContext))] + [Migration("20260917114349_AddIntegrationCredentialCreatedSortKey")] + partial class AddIntegrationCredentialCreatedSortKey + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Helpdesk.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AuthorizationRevision") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0L); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("DisabledAtUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IsInstanceAdministrator") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Helpdesk.Infrastructure.Identity.IntegrationCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUnixMilliseconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Permissions") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OwnerUserId", "CreatedAtUnixMilliseconds", "Id"); + + b.HasIndex("Purpose", "ExpiresAtUtc", "RevokedAtUtc"); + + b.ToTable("IntegrationCredentials", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917114349_AddIntegrationCredentialCreatedSortKey.cs b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917114349_AddIntegrationCredentialCreatedSortKey.cs new file mode 100644 index 00000000..9d4a05fd --- /dev/null +++ b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917114349_AddIntegrationCredentialCreatedSortKey.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Helpdesk.Infrastructure.SqliteMigrations.Migrations.Identity +{ + /// + public partial class AddIntegrationCredentialCreatedSortKey : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CreatedAtUnixMilliseconds", + table: "IntegrationCredentials", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + + migrationBuilder.Sql(""" + UPDATE "IntegrationCredentials" + SET "CreatedAtUnixMilliseconds" = + CAST(strftime('%s', "CreatedAtUtc") AS INTEGER) * 1000 + + CAST(substr(strftime('%f', "CreatedAtUtc"), 4, 3) AS INTEGER) + WHERE "CreatedAtUnixMilliseconds" = 0; + """); + + migrationBuilder.CreateIndex( + name: "IX_IntegrationCredentials_OwnerUserId_CreatedAtUnixMilliseconds_Id", + table: "IntegrationCredentials", + columns: new[] { "OwnerUserId", "CreatedAtUnixMilliseconds", "Id" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_IntegrationCredentials_OwnerUserId_CreatedAtUnixMilliseconds_Id", + table: "IntegrationCredentials"); + + migrationBuilder.DropColumn( + name: "CreatedAtUnixMilliseconds", + table: "IntegrationCredentials"); + } + } +} diff --git a/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/RatelDeskIdentityDbContextModelSnapshot.cs b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/RatelDeskIdentityDbContextModelSnapshot.cs index 27c37e25..cac1bf18 100644 --- a/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/RatelDeskIdentityDbContextModelSnapshot.cs +++ b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/RatelDeskIdentityDbContextModelSnapshot.cs @@ -112,6 +112,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("CreatedAtUnixMilliseconds") + .HasColumnType("INTEGER"); + b.Property("CreatedAtUtc") .HasColumnType("TEXT"); @@ -162,6 +165,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OwnerUserId"); + b.HasIndex("OwnerUserId", "CreatedAtUnixMilliseconds", "Id"); + b.HasIndex("Purpose", "ExpiresAtUtc", "RevokedAtUtc"); b.ToTable("IntegrationCredentials", (string)null); diff --git a/src/Helpdesk.Infrastructure/Identity/IntegrationCredential.cs b/src/Helpdesk.Infrastructure/Identity/IntegrationCredential.cs index b263d6aa..3cd200f5 100644 --- a/src/Helpdesk.Infrastructure/Identity/IntegrationCredential.cs +++ b/src/Helpdesk.Infrastructure/Identity/IntegrationCredential.cs @@ -16,6 +16,11 @@ public sealed class IntegrationCredential public string Permissions { get; set; } = string.Empty; public DateTimeOffset ExpiresAtUtc { get; set; } public DateTimeOffset CreatedAtUtc { get; set; } + /// + /// Provider-neutral UTC sort key for credential listings. SQLite cannot + /// translate ordering over values. + /// + public long CreatedAtUnixMilliseconds { get; set; } public DateTimeOffset? LastUsedAtUtc { get; set; } public DateTimeOffset? RevokedAtUtc { get; set; } } diff --git a/src/Helpdesk.Infrastructure/Identity/Migrations/20260917114328_AddIntegrationCredentialCreatedSortKey.Designer.cs b/src/Helpdesk.Infrastructure/Identity/Migrations/20260917114328_AddIntegrationCredentialCreatedSortKey.Designer.cs new file mode 100644 index 00000000..307e489e --- /dev/null +++ b/src/Helpdesk.Infrastructure/Identity/Migrations/20260917114328_AddIntegrationCredentialCreatedSortKey.Designer.cs @@ -0,0 +1,368 @@ +// +using System; +using Helpdesk.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Helpdesk.Infrastructure.Identity.Migrations +{ + [DbContext(typeof(RatelDeskIdentityDbContext))] + [Migration("20260917114328_AddIntegrationCredentialCreatedSortKey")] + partial class AddIntegrationCredentialCreatedSortKey + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Helpdesk.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("AuthorizationRevision") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisabledAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsInstanceAdministrator") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Helpdesk.Infrastructure.Identity.IntegrationCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUnixMilliseconds") + .HasColumnType("bigint"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("OrganizationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Permissions") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OwnerUserId", "CreatedAtUnixMilliseconds", "Id"); + + b.HasIndex("Purpose", "ExpiresAtUtc", "RevokedAtUtc"); + + b.ToTable("IntegrationCredentials", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Helpdesk.Infrastructure/Identity/Migrations/20260917114328_AddIntegrationCredentialCreatedSortKey.cs b/src/Helpdesk.Infrastructure/Identity/Migrations/20260917114328_AddIntegrationCredentialCreatedSortKey.cs new file mode 100644 index 00000000..bbf09757 --- /dev/null +++ b/src/Helpdesk.Infrastructure/Identity/Migrations/20260917114328_AddIntegrationCredentialCreatedSortKey.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Helpdesk.Infrastructure.Identity.Migrations +{ + /// + public partial class AddIntegrationCredentialCreatedSortKey : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CreatedAtUnixMilliseconds", + table: "IntegrationCredentials", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.Sql(""" + UPDATE "IntegrationCredentials" + SET "CreatedAtUnixMilliseconds" = CAST(EXTRACT(EPOCH FROM "CreatedAtUtc") * 1000 AS bigint) + WHERE "CreatedAtUnixMilliseconds" = 0; + """); + + migrationBuilder.CreateIndex( + name: "IX_IntegrationCredentials_OwnerUserId_CreatedAtUnixMillisecond~", + table: "IntegrationCredentials", + columns: new[] { "OwnerUserId", "CreatedAtUnixMilliseconds", "Id" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_IntegrationCredentials_OwnerUserId_CreatedAtUnixMillisecond~", + table: "IntegrationCredentials"); + + migrationBuilder.DropColumn( + name: "CreatedAtUnixMilliseconds", + table: "IntegrationCredentials"); + } + } +} diff --git a/src/Helpdesk.Infrastructure/Identity/Migrations/RatelDeskIdentityDbContextModelSnapshot.cs b/src/Helpdesk.Infrastructure/Identity/Migrations/RatelDeskIdentityDbContextModelSnapshot.cs index dff39734..8b815d2a 100644 --- a/src/Helpdesk.Infrastructure/Identity/Migrations/RatelDeskIdentityDbContextModelSnapshot.cs +++ b/src/Helpdesk.Infrastructure/Identity/Migrations/RatelDeskIdentityDbContextModelSnapshot.cs @@ -117,6 +117,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("CreatedAtUnixMilliseconds") + .HasColumnType("bigint"); + b.Property("CreatedAtUtc") .HasColumnType("timestamp with time zone"); @@ -167,6 +170,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OwnerUserId"); + b.HasIndex("OwnerUserId", "CreatedAtUnixMilliseconds", "Id"); + b.HasIndex("Purpose", "ExpiresAtUtc", "RevokedAtUtc"); b.ToTable("IntegrationCredentials", (string)null); diff --git a/src/Helpdesk.Infrastructure/Identity/RatelDeskIdentityDbContext.cs b/src/Helpdesk.Infrastructure/Identity/RatelDeskIdentityDbContext.cs index b157325e..2c1f41ea 100644 --- a/src/Helpdesk.Infrastructure/Identity/RatelDeskIdentityDbContext.cs +++ b/src/Helpdesk.Infrastructure/Identity/RatelDeskIdentityDbContext.cs @@ -34,6 +34,7 @@ protected override void OnModelCreating(ModelBuilder builder) entity.Property(credential => credential.OrganizationId).HasMaxLength(128); entity.Property(credential => credential.Permissions).HasMaxLength(4096).IsRequired(); entity.HasIndex(credential => credential.OwnerUserId); + entity.HasIndex(credential => new { credential.OwnerUserId, credential.CreatedAtUnixMilliseconds, credential.Id }); entity.HasIndex(credential => new { credential.Purpose, credential.ExpiresAtUtc, credential.RevokedAtUtc }); }); } diff --git a/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs b/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs new file mode 100644 index 00000000..09368494 --- /dev/null +++ b/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs @@ -0,0 +1,153 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text.Encodings.Web; +using Helpdesk.API.Endpoints.Authentication; +using Helpdesk.Infrastructure.Identity; +using Helpdesk.Shared.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using Xunit; + +namespace Helpdesk.Tests.Api; + +public sealed class IntegrationCredentialSqliteTests +{ + [Fact] + public async Task List_endpoint_uses_provider_supported_chronological_ordering_with_a_stable_tie_breaker() + { + await using var harness = await Harness.CreateAsync(); + var older = Credential("owner", "older", DateTimeOffset.Parse("2026-01-01T00:00:00+00:00")); + var firstAtSameInstant = Credential("owner", "first", DateTimeOffset.Parse("2026-01-02T00:00:00+00:00")); + var secondAtSameInstant = Credential("owner", "second", firstAtSameInstant.CreatedAtUtc); + secondAtSameInstant.Id = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"); + + await using (var scope = harness.Services.CreateAsyncScope()) + { + var identity = scope.ServiceProvider.GetRequiredService(); + identity.IntegrationCredentials.AddRange(older, firstAtSameInstant, secondAtSameInstant); + await identity.SaveChangesAsync(); + } + + var response = await harness.Client.GetAsync("/api/v1/integration-credentials/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var credentials = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(credentials); + Assert.Equal(["second", "first", "older"], credentials.Select(credential => credential.Name)); + } + + [Fact] + public async Task Forward_sqlite_migration_backfills_the_sort_key_for_existing_credentials() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection, sqlite => sqlite.MigrationsAssembly("Helpdesk.Infrastructure.SqliteMigrations")) + .Options; + var createdAtUtc = DateTimeOffset.Parse("2026-01-01T12:34:56.789+00:00"); + var credentialId = Guid.NewGuid(); + + await using (var beforeUpgrade = new RatelDeskIdentityDbContext(options)) + { + await beforeUpgrade.GetService().MigrateAsync("20260917095318_AddIntegrationCredentials"); + } + + await using (var insert = connection.CreateCommand()) + { + insert.CommandText = """ + INSERT INTO "IntegrationCredentials" ( + "Id", "OwnerUserId", "Name", "Prefix", "SecretHash", "Purpose", "OrganizationId", "Permissions", + "ExpiresAtUtc", "CreatedAtUtc", "LastUsedAtUtc", "RevokedAtUtc") + VALUES ($id, 'owner', 'existing', 'rdk_existing', 'hash', 'api', 'org-a', 'Incident.Read', + $expiresAt, $createdAt, NULL, NULL); + """; + insert.Parameters.AddWithValue("$id", credentialId.ToString()); + insert.Parameters.AddWithValue("$createdAt", createdAtUtc.ToString("O")); + insert.Parameters.AddWithValue("$expiresAt", createdAtUtc.AddDays(30).ToString("O")); + await insert.ExecuteNonQueryAsync(); + } + + await using (var afterUpgrade = new RatelDeskIdentityDbContext(options)) + await afterUpgrade.Database.MigrateAsync(); + + await using var verify = connection.CreateCommand(); + verify.CommandText = "SELECT \"CreatedAtUnixMilliseconds\" FROM \"IntegrationCredentials\" WHERE \"Name\" = 'existing';"; + Assert.Equal(createdAtUtc.ToUnixTimeMilliseconds(), Convert.ToInt64(await verify.ExecuteScalarAsync())); + } + + private static IntegrationCredential Credential(string ownerId, string name, DateTimeOffset createdAtUtc) => new() + { + Id = Guid.NewGuid(), + OwnerUserId = ownerId, + Name = name, + Prefix = $"rdk_{name}", + SecretHash = "hash", + Purpose = "api", + OrganizationId = "org-a", + Permissions = "Incident.Read", + CreatedAtUtc = createdAtUtc, + CreatedAtUnixMilliseconds = createdAtUtc.ToUnixTimeMilliseconds(), + ExpiresAtUtc = createdAtUtc.AddDays(30) + }; + + private sealed class Harness(WebApplication application, SqliteConnection connection) : IAsyncDisposable + { + public IServiceProvider Services => application.Services; + + public HttpClient Client { get; } = application.GetTestClient(); + + public static async Task CreateAsync() + { + var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Development" }); + builder.WebHost.UseTestServer(); + builder.Services.AddDbContext(options => options.UseSqlite(connection)); + builder.Services.AddSingleton(Substitute.For()); + builder.Services.AddAuthentication("Test").AddScheme("Test", _ => { }); + builder.Services.AddAuthorization(); + var application = builder.Build(); + application.UseAuthentication(); + application.UseAuthorization(); + application.MapIntegrationCredentialEndpoints(); + await application.StartAsync(); + await using (var scope = application.Services.CreateAsyncScope()) + { + var identity = scope.ServiceProvider.GetRequiredService(); + await identity.Database.EnsureCreatedAsync(); + } + + return new Harness(application, connection); + } + + public async ValueTask DisposeAsync() + { + await application.DisposeAsync(); + await connection.DisposeAsync(); + } + } + + private sealed class TestAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) + { + protected override Task HandleAuthenticateAsync() + { + var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "owner")], Scheme.Name); + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name))); + } + } +} From 682c3a24ec76802d75f06b7f7c43c367aa152b07 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 13:56:53 +0200 Subject: [PATCH 05/32] fix(credentials): require interactive management sessions --- ...rationCredentialManagementAuthorization.cs | 71 +++++++++ .../IntegrationCredentialEndpoints.cs | 38 +++-- src/Helpdesk.API/Program.cs | 10 ++ ...nCredentialManagementAuthorizationTests.cs | 136 ++++++++++++++++++ .../Api/IntegrationCredentialSqliteTests.cs | 22 ++- 5 files changed, 261 insertions(+), 16 deletions(-) create mode 100644 src/Helpdesk.API/Authentication/IntegrationCredentialManagementAuthorization.cs create mode 100644 tests/Helpdesk.Tests/Api/IntegrationCredentialManagementAuthorizationTests.cs diff --git a/src/Helpdesk.API/Authentication/IntegrationCredentialManagementAuthorization.cs b/src/Helpdesk.API/Authentication/IntegrationCredentialManagementAuthorization.cs new file mode 100644 index 00000000..34bce8b3 --- /dev/null +++ b/src/Helpdesk.API/Authentication/IntegrationCredentialManagementAuthorization.cs @@ -0,0 +1,71 @@ +using System.Security.Claims; +using Helpdesk.Infrastructure.Identity; +using Helpdesk.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; + +namespace Helpdesk.API.Authentication; + +/// Resolves the application account allowed to manage integration credentials. +public interface IIntegrationCredentialOwnerResolver +{ + Task ResolveAsync(ClaimsPrincipal principal, CancellationToken cancellationToken = default); +} + +public sealed record IntegrationCredentialOwner(string UserId); + +public sealed class IntegrationCredentialOwnerResolver( + HelpdeskDbContext db, + RatelDeskIdentityDbContext identityDb) : IIntegrationCredentialOwnerResolver +{ + public async Task ResolveAsync(ClaimsPrincipal principal, CancellationToken cancellationToken = default) + { + if (principal.Identity?.IsAuthenticated != true || + principal.HasClaim("auth_mode", "integration") || + principal.HasClaim("auth_mode", "mcp") || + principal.HasClaim("auth_mode", "gateway") || + principal.HasClaim("integration_purpose", "mcp")) + return null; + + if (principal.HasClaim("auth_mode", "local")) + return await ResolveEnabledAccountAsync(principal.FindFirstValue(ClaimTypes.NameIdentifier), cancellationToken); + + var issuer = principal.FindFirstValue("iss")?.TrimEnd('/'); + var subject = principal.FindFirstValue("sub"); + if (string.IsNullOrWhiteSpace(issuer) || string.IsNullOrWhiteSpace(subject)) + return null; + + var localAccountId = await db.CustomerAuthLinks.AsNoTracking() + .Where(link => link.OidcSubject == subject && + (link.OidcIssuer == issuer || link.OidcIssuer == $"{issuer}/")) + .Select(link => link.LocalAccountId) + .SingleOrDefaultAsync(cancellationToken); + + return await ResolveEnabledAccountAsync(localAccountId, cancellationToken); + } + + private async Task ResolveEnabledAccountAsync(string? userId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(userId)) + return null; + + var enabled = await identityDb.Users.AsNoTracking() + .AnyAsync(user => user.Id == userId && user.IsEnabled, cancellationToken); + return enabled ? new IntegrationCredentialOwner(userId) : null; + } +} + +public sealed class IntegrationCredentialManagementSessionRequirement : IAuthorizationRequirement; + +public sealed class IntegrationCredentialManagementSessionHandler( + IIntegrationCredentialOwnerResolver ownerResolver) + : AuthorizationHandler +{ + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + IntegrationCredentialManagementSessionRequirement requirement) + { + if (await ownerResolver.ResolveAsync(context.User) is not null) + context.Succeed(requirement); + } +} diff --git a/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs b/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs index 6a85f5bd..7772519c 100644 --- a/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs +++ b/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using System.Security.Cryptography; using System.Text; +using Helpdesk.API.Authentication; using Helpdesk.Infrastructure.Identity; using Helpdesk.Shared.Auth; using Helpdesk.Shared.Services; @@ -11,6 +12,7 @@ namespace Helpdesk.API.Endpoints.Authentication; public static class IntegrationCredentialEndpoints { + public const string CredentialManagementPolicy = "IntegrationCredentialManagementSession"; private const int DefaultLifetimeDays = 30; private const int MaximumLifetimeDays = 90; @@ -18,14 +20,14 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder { var group = app.MapGroup("/api/v1/integration-credentials") .WithTags("Integration Credentials") - .RequireAuthorization(); + .RequireAuthorization(CredentialManagementPolicy); - group.MapGet("/", async (ClaimsPrincipal principal, RatelDeskIdentityDbContext identityDb, CancellationToken ct) => + group.MapGet("/", async (ClaimsPrincipal principal, IIntegrationCredentialOwnerResolver ownerResolver, RatelDeskIdentityDbContext identityDb, CancellationToken ct) => { - var ownerId = principal.FindFirstValue(ClaimTypes.NameIdentifier); - if (string.IsNullOrWhiteSpace(ownerId)) return Results.Unauthorized(); + var owner = await ownerResolver.ResolveAsync(principal, ct); + if (owner is null) return Results.Forbid(); var credentials = await identityDb.IntegrationCredentials.AsNoTracking() - .Where(credential => credential.OwnerUserId == ownerId) + .Where(credential => credential.OwnerUserId == owner.UserId) .OrderByDescending(credential => credential.CreatedAtUnixMilliseconds) .ThenByDescending(credential => credential.Id) .Select(credential => new IntegrationCredentialMetadata( @@ -37,20 +39,27 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder }).WithSummary("List integration credentials"); group.MapPost("/", async ( - [FromBody] CreateIntegrationCredentialRequest request, + [FromBody] CreateIntegrationCredentialRequest? request, ClaimsPrincipal principal, + HttpContext context, + IIntegrationCredentialOwnerResolver ownerResolver, ICurrentUserAccessService accessService, RatelDeskIdentityDbContext identityDb, CancellationToken ct) => { - var ownerId = principal.FindFirstValue(ClaimTypes.NameIdentifier); - if (string.IsNullOrWhiteSpace(ownerId) || !principal.HasClaim("auth_mode", "local")) return Results.Forbid(); + var owner = await ownerResolver.ResolveAsync(principal, ct); + if (owner is null) return Results.Forbid(); + if (request is null) return Results.ValidationProblem(new Dictionary { ["request"] = ["A credential request is required."] }); if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 128) return Results.ValidationProblem(new Dictionary { ["name"] = ["A credential name up to 128 characters is required."] }); if (request.Purpose is not ("api" or "mcp")) return Results.ValidationProblem(new Dictionary { ["purpose"] = ["Purpose must be api or mcp."] }); if (request.Purpose == "mcp") return Results.ValidationProblem(new Dictionary { ["purpose"] = ["MCP credentials are created through the paired HTTP MCP gateway configuration flow."] }); var access = await accessService.ResolveAsync(principal, ct); - var requestedPermissions = request.Permissions.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var requestedPermissions = request.Permissions? + .Where(permission => !string.IsNullOrWhiteSpace(permission)) + .Select(permission => permission.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() ?? []; if (requestedPermissions.Length == 0 || requestedPermissions.Except(access.Permissions, StringComparer.OrdinalIgnoreCase).Any() || requestedPermissions.Any(permission => !HelpdeskPermissions.AssignablePermissions.Contains(permission, StringComparer.OrdinalIgnoreCase))) { @@ -66,7 +75,7 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder var credential = new IntegrationCredential { Id = id, - OwnerUserId = ownerId, + OwnerUserId = owner.UserId, Name = request.Name.Trim(), Prefix = prefix, SecretHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(secret))), @@ -79,16 +88,17 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder }; identityDb.IntegrationCredentials.Add(credential); await identityDb.SaveChangesAsync(ct); + context.Response.Headers.CacheControl = "no-store"; return Results.Created($"/api/v1/integration-credentials/{credential.Id:N}", new CreatedIntegrationCredential( credential.Id, credential.Prefix, $"rdk_{credential.Id:N}_{secret}", credential.Purpose, credential.OrganizationId, requestedPermissions, credential.ExpiresAtUtc)); }).WithSummary("Create an API integration credential"); - group.MapDelete("/{credentialId:guid}", async (Guid credentialId, ClaimsPrincipal principal, RatelDeskIdentityDbContext identityDb, CancellationToken ct) => + group.MapDelete("/{credentialId:guid}", async (Guid credentialId, ClaimsPrincipal principal, IIntegrationCredentialOwnerResolver ownerResolver, RatelDeskIdentityDbContext identityDb, CancellationToken ct) => { - var ownerId = principal.FindFirstValue(ClaimTypes.NameIdentifier); - if (string.IsNullOrWhiteSpace(ownerId)) return Results.Unauthorized(); - var credential = await identityDb.IntegrationCredentials.SingleOrDefaultAsync(candidate => candidate.Id == credentialId && candidate.OwnerUserId == ownerId, ct); + var owner = await ownerResolver.ResolveAsync(principal, ct); + if (owner is null) return Results.Forbid(); + var credential = await identityDb.IntegrationCredentials.SingleOrDefaultAsync(candidate => candidate.Id == credentialId && candidate.OwnerUserId == owner.UserId, ct); if (credential is null) return Results.NotFound(); if (credential.RevokedAtUtc is null) { diff --git a/src/Helpdesk.API/Program.cs b/src/Helpdesk.API/Program.cs index 28c6fdc8..44e5b55c 100644 --- a/src/Helpdesk.API/Program.cs +++ b/src/Helpdesk.API/Program.cs @@ -68,6 +68,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Hosting; @@ -944,6 +945,12 @@ await OrchestrationCallbackEndpoints.PublishRejectedAsync( }); builder.Services.AddAuthorization(opts => { + opts.AddPolicy(IntegrationCredentialEndpoints.CredentialManagementPolicy, policy => + { + policy.RequireAuthenticatedUser(); + policy.AddRequirements(new IntegrationCredentialManagementSessionRequirement()); + }); + opts.AddPolicy("HelpdeskAdmin", p => { // Do not pin schemes here so tests (and custom schemes) can satisfy the policy. @@ -1025,6 +1032,9 @@ await OrchestrationCallbackEndpoints.PublishRejectedAsync( }); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + builder.Logging.AddFilter("Microsoft.AspNetCore.Authentication", LogLevel.Debug); builder.Logging.AddFilter("Helpdesk", LogLevel.Warning); diff --git a/tests/Helpdesk.Tests/Api/IntegrationCredentialManagementAuthorizationTests.cs b/tests/Helpdesk.Tests/Api/IntegrationCredentialManagementAuthorizationTests.cs new file mode 100644 index 00000000..9986ac11 --- /dev/null +++ b/tests/Helpdesk.Tests/Api/IntegrationCredentialManagementAuthorizationTests.cs @@ -0,0 +1,136 @@ +using System.Security.Claims; +using Helpdesk.API.Authentication; +using Helpdesk.Infrastructure.Identity; +using Helpdesk.Infrastructure.Persistence; +using Helpdesk.Shared.Models; +using Helpdesk.Shared.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Xunit; + +namespace Helpdesk.Tests.Api; + +public sealed class IntegrationCredentialManagementAuthorizationTests +{ + [Theory] + [InlineData("integration")] + [InlineData("mcp")] + [InlineData("gateway")] + public async Task Non_interactive_credentials_cannot_become_a_management_session(string authenticationMode) + { + await using var fixture = Fixture.Create(); + fixture.Identity.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner", IsEnabled = true }); + await fixture.Identity.SaveChangesAsync(); + var principal = Principal( + new Claim(ClaimTypes.NameIdentifier, "owner"), + new Claim("auth_mode", authenticationMode)); + + Assert.Null(await fixture.Resolver.ResolveAsync(principal)); + Assert.False(await IsAuthorizedAsync(fixture.Resolver, principal)); + } + + [Fact] + public async Task Local_enabled_account_can_manage_its_own_credentials() + { + await using var fixture = Fixture.Create(); + fixture.Identity.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner", IsEnabled = true }); + await fixture.Identity.SaveChangesAsync(); + var principal = Principal( + new Claim(ClaimTypes.NameIdentifier, "owner"), + new Claim("auth_mode", "local")); + + Assert.Equal("owner", (await fixture.Resolver.ResolveAsync(principal))?.UserId); + Assert.True(await IsAuthorizedAsync(fixture.Resolver, principal)); + } + + [Fact] + public async Task Linked_oidc_identity_resolves_the_persisted_application_account_not_the_provider_subject() + { + await using var fixture = Fixture.Create(); + fixture.Identity.Users.Add(new ApplicationUser { Id = "application-user", UserName = "application-user", IsEnabled = true }); + fixture.Application.CustomerAuthLinks.Add(new CustomerAuthLink + { + Id = "link-1", + CustomerId = "customer-1", + OidcIssuer = "https://issuer.example", + OidcSubject = "provider-subject", + LocalAccountId = "application-user" + }); + await fixture.Identity.SaveChangesAsync(); + await fixture.Application.SaveChangesAsync(); + var principal = Principal( + new Claim(ClaimTypes.NameIdentifier, "provider-subject"), + new Claim("iss", "https://issuer.example/"), + new Claim("sub", "provider-subject")); + + Assert.Equal("application-user", (await fixture.Resolver.ResolveAsync(principal))?.UserId); + Assert.True(await IsAuthorizedAsync(fixture.Resolver, principal)); + } + + [Fact] + public async Task Disabled_linked_account_cannot_manage_credentials() + { + await using var fixture = Fixture.Create(); + fixture.Identity.Users.Add(new ApplicationUser { Id = "disabled", UserName = "disabled", IsEnabled = false }); + fixture.Application.CustomerAuthLinks.Add(new CustomerAuthLink + { + Id = "link-2", + CustomerId = "customer-2", + OidcIssuer = "https://issuer.example", + OidcSubject = "provider-subject", + LocalAccountId = "disabled" + }); + await fixture.Identity.SaveChangesAsync(); + await fixture.Application.SaveChangesAsync(); + + Assert.Null(await fixture.Resolver.ResolveAsync(Principal( + new Claim("iss", "https://issuer.example"), + new Claim("sub", "provider-subject")))); + } + + private static ClaimsPrincipal Principal(params Claim[] claims) => + new(new ClaimsIdentity(claims, "Test", ClaimTypes.Name, ClaimTypes.Role)); + + private static async Task IsAuthorizedAsync(IIntegrationCredentialOwnerResolver resolver, ClaimsPrincipal principal) + { + var context = new AuthorizationHandlerContext( + [new IntegrationCredentialManagementSessionRequirement()], principal, resource: null); + await new IntegrationCredentialManagementSessionHandler(resolver).HandleAsync(context); + return context.HasSucceeded; + } + + private sealed class Fixture : IAsyncDisposable + { + private Fixture(HelpdeskDbContext application, RatelDeskIdentityDbContext identity) + { + Application = application; + Identity = identity; + Resolver = new IntegrationCredentialOwnerResolver(application, identity); + } + + public HelpdeskDbContext Application { get; } + + public RatelDeskIdentityDbContext Identity { get; } + + public IntegrationCredentialOwnerResolver Resolver { get; } + + public static Fixture Create() + { + var application = new HelpdeskDbContext( + new DbContextOptionsBuilder().UseInMemoryDatabase($"credentials-app-{Guid.NewGuid():N}").Options, + Substitute.For(), + new HttpContextAccessor()); + var identity = new RatelDeskIdentityDbContext( + new DbContextOptionsBuilder().UseInMemoryDatabase($"credentials-identity-{Guid.NewGuid():N}").Options); + return new Fixture(application, identity); + } + + public async ValueTask DisposeAsync() + { + await Application.DisposeAsync(); + await Identity.DisposeAsync(); + } + } +} diff --git a/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs b/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs index 09368494..0b2c60c8 100644 --- a/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs +++ b/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs @@ -3,9 +3,11 @@ using System.Security.Claims; using System.Text.Encodings.Web; using Helpdesk.API.Endpoints.Authentication; +using Helpdesk.API.Authentication; using Helpdesk.Infrastructure.Identity; using Helpdesk.Shared.Services; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; @@ -115,8 +117,14 @@ public static async Task CreateAsync() builder.WebHost.UseTestServer(); builder.Services.AddDbContext(options => options.UseSqlite(connection)); builder.Services.AddSingleton(Substitute.For()); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddAuthentication("Test").AddScheme("Test", _ => { }); - builder.Services.AddAuthorization(); + builder.Services.AddAuthorization(options => options.AddPolicy(IntegrationCredentialEndpoints.CredentialManagementPolicy, policy => + { + policy.RequireAuthenticatedUser(); + policy.AddRequirements(new IntegrationCredentialManagementSessionRequirement()); + })); var application = builder.Build(); application.UseAuthentication(); application.UseAuthorization(); @@ -126,6 +134,8 @@ public static async Task CreateAsync() { var identity = scope.ServiceProvider.GetRequiredService(); await identity.Database.EnsureCreatedAsync(); + identity.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner", Email = "owner@example.test", IsEnabled = true }); + await identity.SaveChangesAsync(); } return new Harness(application, connection); @@ -146,8 +156,16 @@ private sealed class TestAuthenticationHandler( { protected override Task HandleAuthenticateAsync() { - var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "owner")], Scheme.Name); + var identity = new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, "owner"), new Claim("auth_mode", "local")], + Scheme.Name); return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name))); } } + + private sealed class StaticOwnerResolver : IIntegrationCredentialOwnerResolver + { + public Task ResolveAsync(ClaimsPrincipal principal, CancellationToken cancellationToken = default) + => Task.FromResult(new IntegrationCredentialOwner("owner")); + } } From 036cff0995d0512e612d65e830b72a842aacc5b3 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 14:17:23 +0200 Subject: [PATCH 06/32] fix(openapi): describe endpoint auth accurately --- .../Documentation/RatelDeskOpenApiCatalog.cs | 129 +++++++++++++++++- src/Helpdesk.API/Program.cs | 34 +---- .../Api/OpenApiAndVersionEndpointsTests.cs | 36 +++++ 3 files changed, 164 insertions(+), 35 deletions(-) diff --git a/src/Helpdesk.API/Documentation/RatelDeskOpenApiCatalog.cs b/src/Helpdesk.API/Documentation/RatelDeskOpenApiCatalog.cs index a56ada29..9091c4f4 100644 --- a/src/Helpdesk.API/Documentation/RatelDeskOpenApiCatalog.cs +++ b/src/Helpdesk.API/Documentation/RatelDeskOpenApiCatalog.cs @@ -1,6 +1,8 @@ using System.Text.Json.Nodes; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; namespace Helpdesk.API.Documentation; @@ -8,6 +10,13 @@ namespace Helpdesk.API.Documentation; /// Single source of truth for the public API reference navigation. public static class RatelDeskOpenApiCatalog { + private const string JwtBearerScheme = "JwtBearer"; + private const string IntegrationCredentialScheme = "IntegrationCredential"; + private const string LocalSessionScheme = "LocalSession"; + private const string AiAgentScheme = "AiAgentJwt"; + private const string OrchestrationScheme = "OrchestrationM2M"; + private const string SystemScheme = "SystemToken"; + private sealed record Tag(string Name, string Group, string Description); private static readonly Tag[] Tags = @@ -56,7 +65,71 @@ public static Task TransformDocumentAsync(OpenApiDocument document, Cancellation return Task.CompletedTask; } - public static Task TransformOperationAsync(OpenApiOperation operation, ApiDescription description, CancellationToken cancellationToken) + /// + /// Documents the authentication mechanisms an endpoint actually accepts. This deliberately + /// does not set document-level security: a document default would make public endpoints look + /// protected unless every operation supplied a security override. + /// + public static void ConfigureSecuritySchemes(OpenApiDocument document, string localCookieName) + { + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes ??= new Dictionary(); + + document.Components.SecuritySchemes[JwtBearerScheme] = new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "OIDC or local development JWT in the Authorization header." + }; + document.Components.SecuritySchemes[IntegrationCredentialScheme] = new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "opaque rdk credential", + In = ParameterLocation.Header, + Description = "Opaque integration credential: `Bearer rdk__`. It is not a JWT and is scoped to its organization and permissions." + }; + document.Components.SecuritySchemes[LocalSessionScheme] = new OpenApiSecurityScheme + { + Name = localCookieName, + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Cookie, + Description = "Local browser session cookie. State-changing local-session requests also require the application's CSRF protection." + }; + document.Components.SecuritySchemes[AiAgentScheme] = new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "JWT issued for the configured AI-agent machine identity." + }; + document.Components.SecuritySchemes[OrchestrationScheme] = new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "Client-credentials JWT for the configured orchestration provider." + }; + document.Components.SecuritySchemes[SystemScheme] = new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "Machine-only system JWT. It is not an end-user or integration credential." + }; + } + + public static Task TransformOperationAsync(OpenApiOperation operation, ApiDescription description, OpenApiDocument document, IServiceProvider applicationServices, CancellationToken cancellationToken) { var tag = operation.Tags?.Select(item => item.Name).FirstOrDefault(); if (!string.IsNullOrWhiteSpace(tag) && CanonicalNames.TryGetValue(tag, out var canonical)) @@ -65,9 +138,59 @@ public static Task TransformOperationAsync(OpenApiOperation operation, ApiDescri operation.Tags.Add(new OpenApiTagReference(canonical)); } - var metadata = description.ActionDescriptor.EndpointMetadata; - if (metadata?.OfType().Any() == true) + var metadata = ResolveEndpointMetadata(description, applicationServices); + if (metadata.OfType().Any()) + { + operation.Security = []; + return Task.CompletedTask; + } + + var authorization = metadata.OfType().ToArray(); + if (authorization.Length == 0) + { + // No document-level default exists, but make the public contract explicit in JSON. operation.Security = []; + return Task.CompletedTask; + } + + var policies = authorization + .Select(item => item.Policy) + .Where(policy => !string.IsNullOrWhiteSpace(policy)) + .ToHashSet(StringComparer.Ordinal); + + operation.Security = policies switch + { + _ when policies.Contains("AuthentikAiAgentApi") => Requirements(document, AiAgentScheme), + _ when policies.Contains("OrchestrationM2MOnly") => Requirements(document, OrchestrationScheme), + _ when policies.Contains("SystemBlazorWeb") => Requirements(document, SystemScheme), + _ when policies.Contains("IntegrationCredentialManagementSession") => Requirements(document, JwtBearerScheme, LocalSessionScheme), + _ => Requirements(document, JwtBearerScheme, IntegrationCredentialScheme, LocalSessionScheme) + }; return Task.CompletedTask; } + + private static List Requirements(OpenApiDocument document, params string[] schemes) => + schemes.Select(scheme => new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference(scheme, document)] = [] + }).ToList(); + + private static IEnumerable ResolveEndpointMetadata(ApiDescription description, IServiceProvider applicationServices) + { + var relativePath = description.RelativePath?.Trim('/'); + var endpoint = applicationServices.GetServices() + .SelectMany(source => source.Endpoints) + .OfType() + .FirstOrDefault(candidate => + string.Equals(candidate.RoutePattern.RawText?.Trim('/'), relativePath, StringComparison.OrdinalIgnoreCase) && + candidate.Metadata.GetMetadata()?.HttpMethods + .Any(method => string.Equals(method, description.HttpMethod, StringComparison.OrdinalIgnoreCase)) == true); + + // ApiExplorer currently omits group-level authorization metadata for some minimal API + // routes. The endpoint data source contains the effective runtime metadata, which is + // what authorization middleware will evaluate. + return endpoint is not null + ? endpoint.Metadata + : description.ActionDescriptor.EndpointMetadata ?? []; + } } diff --git a/src/Helpdesk.API/Program.cs b/src/Helpdesk.API/Program.cs index 44e5b55c..4458cc41 100644 --- a/src/Helpdesk.API/Program.cs +++ b/src/Helpdesk.API/Program.cs @@ -82,7 +82,6 @@ using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; -using Microsoft.OpenApi; using Microsoft.AspNetCore.Identity; using Npgsql; using System.IdentityModel.Tokens.Jwt; @@ -1044,43 +1043,14 @@ await OrchestrationCallbackEndpoints.PublishRejectedAsync( options.AddDocumentTransformer((document, context, cancellationToken) => { RatelDeskOpenApiCatalog.TransformDocumentAsync(document, cancellationToken); - document.Components ??= new OpenApiComponents(); - document.Components.SecuritySchemes ??= new Dictionary(); - document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme - { - Name = "Authorization", - Type = SecuritySchemeType.Http, - Scheme = "Bearer", - BearerFormat = "JWT", - In = ParameterLocation.Header, - Description = "JWT Authorization header using the Bearer scheme" - }; - - document.Security ??= new List(); - if (!document.Security.Any(requirement => - requirement.Keys.Any(scheme => string.Equals(scheme.Reference?.Id, "Bearer", StringComparison.OrdinalIgnoreCase)))) - { - document.Security.Add(new OpenApiSecurityRequirement - { - [new OpenApiSecuritySchemeReference("Bearer", document)] = [] - }); - } + RatelDeskOpenApiCatalog.ConfigureSecuritySchemes(document, localAuthenticationCookieName); return Task.CompletedTask; }); options.AddOperationTransformer((operation, context, cancellationToken) => { - RatelDeskOpenApiCatalog.TransformOperationAsync(operation, context.Description, cancellationToken); - operation.Security ??= new List(); - if (!operation.Security.Any(requirement => - requirement.Keys.Any(scheme => string.Equals(scheme.Reference?.Id, "Bearer", StringComparison.OrdinalIgnoreCase)))) - { - operation.Security.Add(new OpenApiSecurityRequirement - { - [new OpenApiSecuritySchemeReference("Bearer")] = [] - }); - } + RatelDeskOpenApiCatalog.TransformOperationAsync(operation, context.Description, context.Document, context.ApplicationServices, cancellationToken); return Task.CompletedTask; }); diff --git a/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs b/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs index 7d9f7fe7..9f0010bc 100644 --- a/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs +++ b/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs @@ -3,7 +3,9 @@ using Helpdesk.API; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace Helpdesk.Tests.Api; @@ -47,6 +49,40 @@ public async Task OpenApi_V1_Json_IsServed() Assert.Equal("RatelDesk API", document.RootElement.GetProperty("info").GetProperty("title").GetString()); Assert.True(document.RootElement.TryGetProperty("x-tagGroups", out var groups)); Assert.Contains(groups.EnumerateArray(), group => group.GetProperty("name").GetString() == "Ticketing"); + + var schemes = document.RootElement.GetProperty("components").GetProperty("securitySchemes"); + Assert.Equal("JWT", schemes.GetProperty("JwtBearer").GetProperty("bearerFormat").GetString()); + Assert.Equal("opaque rdk credential", schemes.GetProperty("IntegrationCredential").GetProperty("bearerFormat").GetString()); + Assert.Equal("cookie", schemes.GetProperty("LocalSession").GetProperty("in").GetString()); + + var integrationCredentialsEndpoint = _factory.Services.GetServices() + .SelectMany(source => source.Endpoints) + .OfType() + .Single(endpoint => endpoint.RoutePattern.RawText?.TrimEnd('/') == "/api/v1/integration-credentials" && + endpoint.Metadata.GetMetadata()?.HttpMethods.Contains("GET") == true); + Assert.Contains(integrationCredentialsEndpoint.Metadata, metadata => metadata is Microsoft.AspNetCore.Authorization.IAuthorizeData); + + Assert.Empty(SecuritySchemes(document, "/api/v1/setup/status", "get")); + Assert.Empty(SecuritySchemes(document, "/api/v1/tickets/public/view", "get")); + Assert.Equal(["JwtBearer", "LocalSession"], SecuritySchemes(document, "/api/v1/integration-credentials", "get")); + Assert.Equal(["OrchestrationM2M"], SecuritySchemes(document, "/api/v1/orchestration/provider/m2m/ping", "get")); + Assert.Equal(["AiAgentJwt"], SecuritySchemes(document, "/api/v1/auth/ai-agent/status", "get")); + Assert.Equal(["IntegrationCredential", "JwtBearer", "LocalSession"], SecuritySchemes(document, "/api/v1/incidents", "get")); + } + + private static string[] SecuritySchemes(JsonDocument document, string path, string method) + { + var paths = document.RootElement.GetProperty("paths"); + Assert.True(paths.TryGetProperty(path, out var pathItem), + $"OpenAPI path '{path}' was not generated. Available paths: {string.Join(", ", paths.EnumerateObject().Select(item => item.Name))}"); + Assert.True(pathItem.TryGetProperty(method, out var operation), + $"OpenAPI operation '{method}' was not generated for path '{path}'."); + return operation.TryGetProperty("security", out var security) + ? security.EnumerateArray() + .SelectMany(requirement => requirement.EnumerateObject().Select(property => property.Name)) + .Order(StringComparer.Ordinal) + .ToArray() + : []; } } From ebf2fc3524a6c1650cc07b8afedcfeaafc98250b Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 14:21:15 +0200 Subject: [PATCH 07/32] fix(mcp): support integration credential diagnostics --- src/Helpdesk.Cli/CliRuntime.cs | 12 +++++++- src/Helpdesk.Cli/HelpdeskCli.Introspection.cs | 14 ++++++--- src/Helpdesk.Mcp.Core/Tools/HelpdeskTools.cs | 11 +++++-- tests/Helpdesk.Tests/Cli/HelpdeskCliTests.cs | 29 +++++++++++++++++++ .../Mcp/HelpdeskToolsMutationTests.cs | 20 +++++++++++++ 5 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/Helpdesk.Cli/CliRuntime.cs b/src/Helpdesk.Cli/CliRuntime.cs index a71cb4a7..680a22c0 100644 --- a/src/Helpdesk.Cli/CliRuntime.cs +++ b/src/Helpdesk.Cli/CliRuntime.cs @@ -1,4 +1,6 @@ using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using Helpdesk.AgentClient; @@ -25,7 +27,7 @@ public HttpClient CreateHttpClient(Uri baseAddress) public async Task GetAccessTokenAsync(ResolvedCliConfig config, CancellationToken ct = default) { - var key = $"{config.ApiBaseUrl}|{config.CredentialMode}|{config.AuthentikTokenUrl}|{config.AuthentikClientId}|{config.AuthentikUsername}|{config.AuthentikScope}"; + var key = $"{config.ApiBaseUrl}|{config.CredentialMode}|{config.AuthentikTokenUrl}|{config.AuthentikClientId}|{config.AuthentikUsername}|{config.AuthentikScope}|{CredentialIdentity(config)}"; if (!_agentClients.TryGetValue(key, out var agentClient)) { agentClient = new HelpdeskAgentClient(new AgentClientConfiguration(config.ApiBaseUrl.ToString(), config.AuthentikTokenUrl, config.AuthentikClientId, config.AuthentikUsername, config.AuthentikAppPassword, config.AuthentikScope, config.AgentUserEmail) @@ -46,4 +48,12 @@ public async Task CreateAuthenticatedClientAsync(ResolvedCliConfig c client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", await GetAccessTokenAsync(config, ct).ConfigureAwait(false)); return client; } + + private static string CredentialIdentity(ResolvedCliConfig config) + { + var value = string.Equals(config.CredentialMode, "integration", StringComparison.OrdinalIgnoreCase) + ? config.IntegrationCredential + : config.AuthentikAppPassword; + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty))); + } } diff --git a/src/Helpdesk.Cli/HelpdeskCli.Introspection.cs b/src/Helpdesk.Cli/HelpdeskCli.Introspection.cs index 1983b989..af10708f 100644 --- a/src/Helpdesk.Cli/HelpdeskCli.Introspection.cs +++ b/src/Helpdesk.Cli/HelpdeskCli.Introspection.cs @@ -29,6 +29,7 @@ private static Command BuildCapabilitiesCommand(CliRuntime runtime, GlobalOption ["profile"] = new JsonObject { ["apiBaseUrl"] = string.IsNullOrWhiteSpace(profile.ApiBaseUrl) ? null : profile.ApiBaseUrl, + ["credentialMode"] = CredentialMode(profile), ["authConfigured"] = HasAuthConfig(profile) }, ["output"] = new JsonObject @@ -243,11 +244,16 @@ private static async Task WriteIntrospectionAsync(CliRuntime runtime, Invocation } } + private static string CredentialMode(CliConfig profile) + => string.IsNullOrWhiteSpace(profile.CredentialMode) ? "authentik" : profile.CredentialMode.Trim().ToLowerInvariant(); + private static bool HasAuthConfig(CliConfig profile) - => !string.IsNullOrWhiteSpace(profile.AuthentikTokenUrl) - && !string.IsNullOrWhiteSpace(profile.AuthentikClientId) - && !string.IsNullOrWhiteSpace(profile.AuthentikUsername) - && !string.IsNullOrWhiteSpace(profile.AuthentikAppPassword); + => CredentialMode(profile) == "integration" + ? !string.IsNullOrWhiteSpace(profile.IntegrationCredential) + : !string.IsNullOrWhiteSpace(profile.AuthentikTokenUrl) + && !string.IsNullOrWhiteSpace(profile.AuthentikClientId) + && !string.IsNullOrWhiteSpace(profile.AuthentikUsername) + && !string.IsNullOrWhiteSpace(profile.AuthentikAppPassword); private static List BuildExampleEntries() => diff --git a/src/Helpdesk.Mcp.Core/Tools/HelpdeskTools.cs b/src/Helpdesk.Mcp.Core/Tools/HelpdeskTools.cs index 55b51c86..bc3ccc41 100644 --- a/src/Helpdesk.Mcp.Core/Tools/HelpdeskTools.cs +++ b/src/Helpdesk.Mcp.Core/Tools/HelpdeskTools.cs @@ -46,8 +46,9 @@ public HelpdeskTools(IHelpdeskAgentClient client, AgentClientConfigurationStore "taskId", "title", "description", "priority", "assignedToId", "clearAssignment", "linkedAssetIds", "attachments", "dueDate", "clearDueDate" }; - [McpServerTool(UseStructuredContent = true), Description("Read Helpdesk AI-agent authentication status. The MCP server never returns bearer tokens.")] - public Task helpdesk_auth(string operation = "status", JsonElement? request = null, bool confirm = false, CancellationToken cancellationToken = default) => Read("helpdesk_auth", operation, ["status"], "/api/v1/auth/ai-agent/status", null, cancellationToken); + [McpServerTool(UseStructuredContent = true), Description("Read authentication status for the configured Helpdesk credential. Integration credentials use the application identity endpoint; Authentik agents use the AI-agent status endpoint. The MCP server never returns bearer tokens.")] + public Task helpdesk_auth(string operation = "status", JsonElement? request = null, bool confirm = false, CancellationToken cancellationToken = default) + => Read("helpdesk_auth", operation, ["status"], AuthStatusPath, null, cancellationToken); [McpServerTool(UseStructuredContent = true), Description("Read live, readiness, and authenticated Helpdesk health.")] public async Task helpdesk_health(string operation = "get", JsonElement? request = null, bool confirm = false, CancellationToken cancellationToken = default) @@ -695,5 +696,9 @@ private static bool TryRequest(JsonElement? request, out T value, out string private static string? String(JsonElement? element, string property) => element is { ValueKind: JsonValueKind.Object } value && value.TryGetProperty(property, out var node) && node.ValueKind == JsonValueKind.String ? node.GetString() : null; private static bool IsHttpUrl(string? value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"; private static string Query(string path, JsonObject? values) => values is null || values.Count == 0 ? path : path + "?" + string.Join("&", values.Where(x => x.Value is not null && x.Key is not "id" && !x.Key.EndsWith("Id", StringComparison.Ordinal)).Select(x => Uri.EscapeDataString(x.Key) + "=" + Uri.EscapeDataString(x.Value!.ToString()))); - private JsonNode Capabilities() => new JsonObject { ["server"] = "Helpdesk.Mcp", ["instance"] = hostContext.Instance, ["catalogRevision"] = hostContext.CatalogRevision, ["transport"] = hostContext.Transport, ["resourceUri"] = hostContext.ResourceUri, ["apiBaseUrl"] = hostContext.CanonicalApiBaseUrl, ["phase"] = 3, ["mutationsEnabled"] = true, ["rawEnabled"] = false, ["mutationConfirmationRequired"] = true, ["configurationWritesEnabled"] = configurationSurface.CanPersist, ["obsoleteMutationProofToolExposed"] = false }; + private string AuthStatusPath => string.Equals(client.Configuration?.CredentialMode, "integration", StringComparison.OrdinalIgnoreCase) + ? "/api/v1/auth/me" + : "/api/v1/auth/ai-agent/status"; + + private JsonNode Capabilities() => new JsonObject { ["server"] = "Helpdesk.Mcp", ["instance"] = hostContext.Instance, ["catalogRevision"] = hostContext.CatalogRevision, ["transport"] = hostContext.Transport, ["resourceUri"] = hostContext.ResourceUri, ["apiBaseUrl"] = hostContext.CanonicalApiBaseUrl, ["credentialMode"] = client.Configuration?.CredentialMode ?? "authentik", ["authStatusPath"] = AuthStatusPath, ["phase"] = 3, ["mutationsEnabled"] = true, ["rawEnabled"] = false, ["mutationConfirmationRequired"] = true, ["configurationWritesEnabled"] = configurationSurface.CanPersist, ["obsoleteMutationProofToolExposed"] = false }; } diff --git a/tests/Helpdesk.Tests/Cli/HelpdeskCliTests.cs b/tests/Helpdesk.Tests/Cli/HelpdeskCliTests.cs index d56e6603..2bf830d2 100644 --- a/tests/Helpdesk.Tests/Cli/HelpdeskCliTests.cs +++ b/tests/Helpdesk.Tests/Cli/HelpdeskCliTests.cs @@ -790,6 +790,35 @@ public async Task CapabilitiesSchemaAndEnums_AreDeterministic_AndSafe() Assert.Contains(enums.RootElement.GetProperty("values").EnumerateArray(), x => x.GetProperty("name").GetString() == "InProgress" && x.GetProperty("value").GetInt32() == 3); } + [Fact] + public async Task Capabilities_reports_integration_credential_configuration_without_Authentik_fields() + { + var temp = Directory.CreateTempSubdirectory("helpdesk-cli-test-"); + var path = Path.Combine(temp.FullName, "cli.json"); + await File.WriteAllTextAsync(path, """{"apiBaseUrl":"https://api.example","credentialMode":"integration","integrationCredential":"rdk_test"}"""); + var output = new StringWriter(); + + var code = await HelpdeskCli.RunAsync(["--config", path, "capabilities", "--json"], new CliRuntime { Out = output, Error = new StringWriter() }); + + Assert.Equal(CliExitCodes.Success, code); + using var document = JsonDocument.Parse(output.ToString()); + var profile = document.RootElement.GetProperty("profile"); + Assert.Equal("integration", profile.GetProperty("credentialMode").GetString()); + Assert.True(profile.GetProperty("authConfigured").GetBoolean()); + Assert.DoesNotContain("rdk_test", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task Runtime_does_not_reuse_an_integration_credential_after_configuration_changes() + { + var runtime = new CliRuntime(); + var first = new ResolvedCliConfig(new Uri("https://api.example"), null, null, null, null, null, null, "integration", "rdk_first"); + var second = first with { IntegrationCredential = "rdk_second" }; + + Assert.Equal("rdk_first", await runtime.GetAccessTokenAsync(first)); + Assert.Equal("rdk_second", await runtime.GetAccessTokenAsync(second)); + } + [Fact] public async Task IncidentsList_RequesterEmail_UsesExpectedQuery() { diff --git a/tests/Helpdesk.Tests/Mcp/HelpdeskToolsMutationTests.cs b/tests/Helpdesk.Tests/Mcp/HelpdeskToolsMutationTests.cs index 158e704e..425a6c6f 100644 --- a/tests/Helpdesk.Tests/Mcp/HelpdeskToolsMutationTests.cs +++ b/tests/Helpdesk.Tests/Mcp/HelpdeskToolsMutationTests.cs @@ -28,6 +28,26 @@ public async Task Outbound_timeout_is_returned_as_a_structured_tool_failure() Assert.True(response.Failure?.Retryable); } + [Fact] + public async Task Integration_credential_auth_status_uses_application_identity_endpoint() + { + var client = Substitute.For(); + client.Configuration.Returns(new AgentClientConfiguration("https://api.example", null, null, null, null, null, null) + { + CredentialMode = "integration", + IntegrationCredential = "rdk_test" + }); + client.GetAsync("/api/v1/auth/me", true, Arg.Any()) + .Returns(new JsonObject { ["authMode"] = "integration" }); + var tools = new HelpdeskTools(client, Store()); + + var response = await tools.helpdesk_auth(cancellationToken: CancellationToken.None); + + Assert.True(response.Success); + await client.Received(1).GetAsync("/api/v1/auth/me", true, Arg.Any()); + await client.DidNotReceive().GetAsync("/api/v1/auth/ai-agent/status", true, Arg.Any()); + } + [Theory] [InlineData("helpdesk_incidents", "state", "{\"incidentId\":\"INC-123\",\"newState\":\"Resolved\"}")] [InlineData("helpdesk_requests", "assign", "{\"ids\":[\"REQ-123\"],\"assignedToId\":\"user-1\"}")] From cf0333f08ab20b5f2cb5062d5614727cf9a99439 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 14:23:42 +0200 Subject: [PATCH 08/32] fix(openapi): expose API build version --- src/Helpdesk.API/Program.cs | 2 ++ tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs | 1 + 2 files changed, 3 insertions(+) diff --git a/src/Helpdesk.API/Program.cs b/src/Helpdesk.API/Program.cs index 4458cc41..8f811967 100644 --- a/src/Helpdesk.API/Program.cs +++ b/src/Helpdesk.API/Program.cs @@ -42,6 +42,7 @@ using Helpdesk.API.Services; using Helpdesk.API.Middleware; using Helpdesk.Shared.Auth; +using Helpdesk.Shared.Build; using Helpdesk.API.Ops; using Helpdesk.API.Validators; using Helpdesk.API.Background; @@ -1043,6 +1044,7 @@ await OrchestrationCallbackEndpoints.PublishRejectedAsync( options.AddDocumentTransformer((document, context, cancellationToken) => { RatelDeskOpenApiCatalog.TransformDocumentAsync(document, cancellationToken); + document.Info.Version = BuildInfoProvider.FromAssembly(typeof(Program).Assembly, builder.Environment.EnvironmentName).Version; RatelDeskOpenApiCatalog.ConfigureSecuritySchemes(document, localAuthenticationCookieName); return Task.CompletedTask; diff --git a/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs b/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs index 9f0010bc..b3156140 100644 --- a/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs +++ b/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs @@ -47,6 +47,7 @@ public async Task OpenApi_V1_Json_IsServed() Assert.Contains("/api/v1/system/version", content, StringComparison.Ordinal); using var document = JsonDocument.Parse(content); Assert.Equal("RatelDesk API", document.RootElement.GetProperty("info").GetProperty("title").GetString()); + Assert.False(string.IsNullOrWhiteSpace(document.RootElement.GetProperty("info").GetProperty("version").GetString())); Assert.True(document.RootElement.TryGetProperty("x-tagGroups", out var groups)); Assert.Contains(groups.EnumerateArray(), group => group.GetProperty("name").GetString() == "Ticketing"); From 2ac31408b7a686d1a244bc87971f5cd6a2030a69 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 14:27:53 +0200 Subject: [PATCH 09/32] fix(release): attach validated draft assets --- .github/workflows/pull-request-validation.yml | 9 +- .github/workflows/release.yml | 45 ++++++--- tools/release/prepare-release-manifest.sh | 68 +++++++++++++ tools/release/publish-release-assets.sh | 69 ++++++++++++++ tools/release/test-publish-release-assets.sh | 95 +++++++++++++++++++ 5 files changed, 271 insertions(+), 15 deletions(-) create mode 100755 tools/release/prepare-release-manifest.sh create mode 100755 tools/release/publish-release-assets.sh create mode 100755 tools/release/test-publish-release-assets.sh diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 398b1fe5..9fa5f20c 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -198,11 +198,18 @@ jobs: (cd release-assets && sha256sum --check SHA256SUMS) test -s release-assets/release-manifest.json + - name: Test draft release asset upload semantics + run: tools/release/test-publish-release-assets.sh + - name: Upload release rehearsal assets uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-asset-rehearsal - path: release-assets + path: | + release-assets/*.tar.gz + release-assets/*.zip + release-assets/SHA256SUMS + release-assets/release-manifest.json retention-days: 14 compose: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43534ff5..2a0a1a17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,6 +118,20 @@ jobs: - name: Verify executable asset checksums run: (cd release-assets && sha256sum --check SHA256SUMS) + - name: Test draft release asset upload semantics + run: tools/release/test-publish-release-assets.sh + + - name: Transfer validated release payload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-assets + path: | + release-assets/*.tar.gz + release-assets/*.zip + release-assets/SHA256SUMS + release-assets/release-manifest.json + if-no-files-found: error + - name: Start and smoke-test source Compose stack run: | docker compose -f docker/docker-compose.yml config --quiet @@ -321,7 +335,7 @@ jobs: release: name: Create GitHub Release - needs: [metadata, publish-web, publish-api, publish-mcp-http] + needs: [metadata, validation, publish-web, publish-api, publish-mcp-http] runs-on: ubuntu-latest permissions: contents: write @@ -332,6 +346,12 @@ jobs: with: ref: ${{ github.ref }} + - name: Download the validated release payload + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-assets + path: release-assets + - name: Verify public packages and anonymous pulls env: GH_TOKEN: ${{ github.token }} @@ -344,7 +364,7 @@ jobs: docker pull "ghcr.io/bostontechnologies/$image:$VERSION" done - - name: Create release after both images are available + - name: Create draft release after images and payload are verified env: GH_TOKEN: ${{ github.token }} VERSION: ${{ needs.metadata.outputs.version }} @@ -382,15 +402,12 @@ jobs: docker pull ghcr.io/bostontechnologies/rateldesk-api:$VERSION docker pull ghcr.io/bostontechnologies/rateldesk-mcp-http:$VERSION" - if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then - gh release edit "$GITHUB_REF_NAME" \ - --title "RatelDesk $VERSION" \ - "${prerelease_flag[@]}" \ - --notes "$release_notes" - else - gh release create "$GITHUB_REF_NAME" \ - --title "RatelDesk $VERSION" \ - --generate-notes \ - "${prerelease_flag[@]}" \ - --notes "$release_notes" - fi + tools/release/prepare-release-manifest.sh \ + "$VERSION" "$SOURCE_REVISION" release-assets \ + "$WEB_DIGEST" "$API_DIGEST" "$MCP_HTTP_DIGEST" "$GITHUB_REF_NAME" + tools/release/publish-release-assets.sh "$GITHUB_REF_NAME" "$VERSION" release-assets + gh release edit "$GITHUB_REF_NAME" \ + --title "RatelDesk $VERSION" \ + --draft \ + "${prerelease_flag[@]}" \ + --notes "$release_notes" diff --git a/tools/release/prepare-release-manifest.sh b/tools/release/prepare-release-manifest.sh new file mode 100755 index 00000000..0c33b77c --- /dev/null +++ b/tools/release/prepare-release-manifest.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Finalizes the manifest after image publication. SHA256SUMS deliberately covers +# only the immutable archives; the detached manifest checksum avoids a +# checksum/manifest self-reference cycle. +if [[ $# -ne 7 ]]; then + echo "usage: $0 " >&2 + exit 64 +fi + +version="$1" +source_revision="$2" +asset_directory="$3" +web_digest="$4" +api_digest="$5" +mcp_http_digest="$6" +tag="$7" + +if [[ ! -d "$asset_directory" || ! -f "$asset_directory/SHA256SUMS" ]]; then + echo "Release assets and SHA256SUMS are required." >&2 + exit 1 +fi + +(cd "$asset_directory" && sha256sum --check SHA256SUMS) + +python3 - "$asset_directory" "$version" "$source_revision" "$web_digest" "$api_digest" "$mcp_http_digest" "$tag" <<'PY' +import hashlib +import json +import pathlib +import sys + +directory = pathlib.Path(sys.argv[1]) +version, revision, web, api, mcp, tag = sys.argv[2:] +assets = {} +for line in (directory / "SHA256SUMS").read_text(encoding="utf-8").splitlines(): + digest, name = line.split(maxsplit=1) + name = name.strip() + if not (directory / name).is_file(): + raise SystemExit(f"Checksummed asset is missing: {name}") + assets[name] = digest + +expected_archives = { + *(f"rateldesk-cli-{version}-{rid}.{'zip' if rid == 'win-x64' else 'tar.gz'}" for rid in ("linux-x64", "linux-arm64", "win-x64", "osx-x64", "osx-arm64")), + *(f"rateldesk-mcp-stdio-{version}-{rid}.{'zip' if rid == 'win-x64' else 'tar.gz'}" for rid in ("linux-x64", "linux-arm64", "win-x64", "osx-x64", "osx-arm64")), + f"rateldesk-deployment-{version}.tar.gz", +} +if set(assets) != expected_archives: + missing = sorted(expected_archives - set(assets)) + unexpected = sorted(set(assets) - expected_archives) + raise SystemExit(f"Unexpected checksum set; missing={missing}, unexpected={unexpected}") + +manifest = { + "version": version, + "tag": tag, + "sourceRevision": revision, + "supportedRids": ["linux-x64", "linux-arm64", "win-x64", "osx-x64", "osx-arm64"], + "assets": dict(sorted(assets.items())), + "containers": [ + {"image": f"ghcr.io/bostontechnologies/rateldesk-web:{version}", "digest": web}, + {"image": f"ghcr.io/bostontechnologies/rateldesk-api:{version}", "digest": api}, + {"image": f"ghcr.io/bostontechnologies/rateldesk-mcp-http:{version}", "digest": mcp}, + ], +} +(directory / "release-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") +PY + +(cd "$asset_directory" && sha256sum release-manifest.json > release-manifest.json.sha256) diff --git a/tools/release/publish-release-assets.sh b/tools/release/publish-release-assets.sh new file mode 100755 index 00000000..61c35d96 --- /dev/null +++ b/tools/release/publish-release-assets.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Creates or resumes a *draft* GitHub release and appends only verified assets. +# Existing assets are downloaded and checked before reuse; this script never +# overwrites a release asset with different content. +if [[ $# -ne 3 ]]; then + echo "usage: $0 " >&2 + exit 64 +fi + +tag="$1" +version="$2" +asset_directory="$3" +gh_bin="${GH_BIN:-gh}" + +if [[ ! -d "$asset_directory" ]]; then + echo "Release asset directory does not exist: $asset_directory" >&2 + exit 1 +fi + +declare -a rids=(linux-x64 linux-arm64 win-x64 osx-x64 osx-arm64) +declare -a assets=() +for rid in "${rids[@]}"; do + suffix=tar.gz + [[ "$rid" == win-x64 ]] && suffix=zip + assets+=("rateldesk-cli-${version}-${rid}.${suffix}") + assets+=("rateldesk-mcp-stdio-${version}-${rid}.${suffix}") +done +assets+=("rateldesk-deployment-${version}.tar.gz" "SHA256SUMS" "release-manifest.json" "release-manifest.json.sha256") + +for asset in "${assets[@]}"; do + [[ -s "$asset_directory/$asset" ]] || { echo "Expected release asset is missing or empty: $asset" >&2; exit 1; } +done +(cd "$asset_directory" && sha256sum --check SHA256SUMS) +(cd "$asset_directory" && sha256sum --check release-manifest.json.sha256) + +if [[ "${RELEASE_ASSET_DRY_RUN:-false}" == true ]]; then + printf '%s\n' "${assets[@]}" + exit 0 +fi + +metadata="" +if metadata="$($gh_bin release view "$tag" --json isDraft,assets 2>/dev/null)"; then + is_draft="$(python3 -c 'import json,sys; print(str(json.load(sys.stdin).get("isDraft", False)).lower())' <<<"$metadata")" + [[ "$is_draft" == true ]] || { echo "Existing release '$tag' is not a draft; refusing to modify it." >&2; exit 1; } +else + "$gh_bin" release create "$tag" --draft --title "RatelDesk $version" --notes "Release assets are being verified before publication." + metadata='{"assets":[]}' +fi + +temporary_directory="$(mktemp -d)" +trap 'rm -rf "$temporary_directory"' EXIT + +for asset in "${assets[@]}"; do + existing="$(python3 -c 'import json,sys; print("present" if any(item.get("name") == sys.argv[1] for item in json.load(sys.stdin).get("assets", [])) else "")' "$asset" <<<"$metadata")" + if [[ "$existing" == present ]]; then + "$gh_bin" release download "$tag" --pattern "$asset" --dir "$temporary_directory" + if ! cmp --silent "$asset_directory/$asset" "$temporary_directory/$asset"; then + echo "Existing release asset '$asset' conflicts with the validated payload." >&2 + exit 1 + fi + continue + fi + "$gh_bin" release upload "$tag" "$asset_directory/$asset" +done + +final_metadata="$($gh_bin release view "$tag" --json assets)" +python3 -c 'import json,sys; expected=set(sys.argv[1:]); actual={item.get("name") for item in json.load(sys.stdin).get("assets", [])}; missing=sorted(expected-actual); unexpected=sorted(actual-expected); (not (missing or unexpected)) or (_ for _ in ()).throw(SystemExit(f"Draft release assets are incomplete; missing={missing}, unexpected={unexpected}"))' "${assets[@]}" <<<"$final_metadata" diff --git a/tools/release/test-publish-release-assets.sh b/tools/release/test-publish-release-assets.sh new file mode 100755 index 00000000..dc6085b1 --- /dev/null +++ b/tools/release/test-publish-release-assets.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +temporary_directory="$(mktemp -d)" +trap 'rm -rf "$temporary_directory"' EXIT +assets="$temporary_directory/assets" +fake_gh_root="$temporary_directory/fake-gh" +mkdir -p "$assets" "$fake_gh_root/assets" + +version="0.0.0-test" +tag="v$version" +declare -a rids=(linux-x64 linux-arm64 win-x64 osx-x64 osx-arm64) +declare -a archives=() +for rid in "${rids[@]}"; do + suffix=tar.gz + [[ "$rid" == win-x64 ]] && suffix=zip + archives+=("rateldesk-cli-${version}-${rid}.${suffix}" "rateldesk-mcp-stdio-${version}-${rid}.${suffix}") +done +archives+=("rateldesk-deployment-${version}.tar.gz") +for asset in "${archives[@]}"; do printf '%s\n' "$asset" > "$assets/$asset"; done +(cd "$assets" && printf '%s\n' "${archives[@]}" | sort | xargs sha256sum > SHA256SUMS) +"$repository_root/tools/release/prepare-release-manifest.sh" "$version" deadbeef "$assets" sha256:web sha256:api sha256:mcp "$tag" + +cat > "$temporary_directory/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +root="${FAKE_GH_ROOT:?}" +if [[ "$1 $2" == "release view" ]]; then + [[ -f "$root/release" ]] || exit 1 + python3 - "$root" <<'PY' +import json, pathlib, sys +root = pathlib.Path(sys.argv[1]) +print(json.dumps({"isDraft": True, "assets": [{"name": item.name} for item in sorted((root / "assets").iterdir())]})) +PY +elif [[ "$1 $2" == "release create" ]]; then + touch "$root/release" +elif [[ "$1 $2" == "release upload" ]]; then + [[ "${FAKE_GH_FAIL_UPLOAD:-false}" != true ]] || exit 1 + cp "$4" "$root/assets/$(basename "$4")" +elif [[ "$1 $2" == "release download" ]]; then + pattern="" + directory="" + for ((index=1; index <= $#; index++)); do + argument="${!index}" + if [[ "$argument" == --pattern ]]; then next=$((index + 1)); pattern="${!next}"; fi + if [[ "$argument" == --dir ]]; then next=$((index + 1)); directory="${!next}"; fi + done + mkdir -p "$directory" + cp "$root/assets/$pattern" "$directory/$pattern" +else + echo "Unexpected fake gh command: $*" >&2 + exit 64 +fi +EOF +chmod 700 "$temporary_directory/gh" + +RELEASE_ASSET_DRY_RUN=true "$repository_root/tools/release/publish-release-assets.sh" "$tag" "$version" "$assets" > "$temporary_directory/dry-run" +test "$(wc -l < "$temporary_directory/dry-run")" = 14 + +# Missing assets are rejected before any release command is run. +mv "$assets/${archives[0]}" "$temporary_directory/missing-asset" +if RELEASE_ASSET_DRY_RUN=true "$repository_root/tools/release/publish-release-assets.sh" "$tag" "$version" "$assets"; then + echo "Missing release asset unexpectedly succeeded." >&2 + exit 1 +fi +mv "$temporary_directory/missing-asset" "$assets/${archives[0]}" + +# The first run creates a draft and uploads the complete payload. +FAKE_GH_ROOT="$fake_gh_root" GH_BIN="$temporary_directory/gh" "$repository_root/tools/release/publish-release-assets.sh" "$tag" "$version" "$assets" +test "$(find "$fake_gh_root/assets" -maxdepth 1 -type f | wc -l)" = 14 + +# A complete rerun reuses verified assets, while differing content is rejected. +FAKE_GH_ROOT="$fake_gh_root" GH_BIN="$temporary_directory/gh" "$repository_root/tools/release/publish-release-assets.sh" "$tag" "$version" "$assets" + +# A failed upload leaves a resumable draft; a later run verifies its matching +# asset and uploads the remainder. +partial_root="$temporary_directory/partial-gh" +mkdir -p "$partial_root/assets" +touch "$partial_root/release" +cp "$assets/${archives[0]}" "$partial_root/assets/${archives[0]}" +if FAKE_GH_ROOT="$partial_root" FAKE_GH_FAIL_UPLOAD=true GH_BIN="$temporary_directory/gh" "$repository_root/tools/release/publish-release-assets.sh" "$tag" "$version" "$assets"; then + echo "Upload failure unexpectedly succeeded." >&2 + exit 1 +fi +FAKE_GH_ROOT="$partial_root" GH_BIN="$temporary_directory/gh" "$repository_root/tools/release/publish-release-assets.sh" "$tag" "$version" "$assets" +test "$(find "$partial_root/assets" -maxdepth 1 -type f | wc -l)" = 14 + +printf '%s\n' changed > "$assets/${archives[0]}" +(cd "$assets" && printf '%s\n' "${archives[@]}" | sort | xargs sha256sum > SHA256SUMS) +"$repository_root/tools/release/prepare-release-manifest.sh" "$version" deadbeef "$assets" sha256:web sha256:api sha256:mcp "$tag" +if FAKE_GH_ROOT="$fake_gh_root" GH_BIN="$temporary_directory/gh" "$repository_root/tools/release/publish-release-assets.sh" "$tag" "$version" "$assets"; then + echo "Conflicting release asset unexpectedly succeeded." >&2 + exit 1 +fi From e9333f52e686e4450a10ca69de9d1e16e1743a5f Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 14:32:16 +0200 Subject: [PATCH 10/32] fix(compose): validate MCP origin configuration --- docker/docker-compose.mcp.release.yml | 6 +++++- docker/docker-compose.mcp.yml | 6 +++++- docker/examples/mcp-http/.env.example | 4 ++++ docker/examples/mcp-http/README.md | 18 ++++++++++-------- docker/examples/mcp-http/docker-compose.yml | 6 +++++- .../Configuration/AuthentikMcpOptions.cs | 6 ++++++ src/Helpdesk.Mcp.Http/Program.cs | 1 + .../Mcp/AuthentikMcpOptionsValidatorTests.cs | 13 +++++++++++++ 8 files changed, 49 insertions(+), 11 deletions(-) diff --git a/docker/docker-compose.mcp.release.yml b/docker/docker-compose.mcp.release.yml index 42ef3cd8..028315fe 100644 --- a/docker/docker-compose.mcp.release.yml +++ b/docker/docker-compose.mcp.release.yml @@ -10,7 +10,11 @@ services: Helpdesk__Mcp__Instance: ${RATELDESK_MCP_INSTANCE:-local} Helpdesk__Mcp__ExpectedApiBaseUrl: http://api:8222/ Helpdesk__Mcp__PublicResourceUri: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical HTTPS MCP URL, for example https://mcp.example.test/mcp.} - Helpdesk__Mcp__AllowedOrigins: ${RATELDESK_MCP_ALLOWED_ORIGINS:-} + Helpdesk__Mcp__AllowedOrigins__0: ${RATELDESK_MCP_ALLOWED_ORIGIN:?Set the browser origin allowed to call this MCP endpoint.} + Authentication__AuthentikMcp__Authority: ${RATELDESK_MCP_AUTHENTIK_AUTHORITY:?Set the configured external authorization server URL.} + Authentication__AuthentikMcp__Audience: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical HTTPS MCP URL, for example https://mcp.example.test/mcp.} + Authentication__AuthentikMcp__RequiredScopes__0: ${RATELDESK_MCP_REQUIRED_SCOPE:-helpdesk.mcp} + Authentication__AuthentikMcp__RequiredGroups__0: ${RATELDESK_MCP_REQUIRED_GROUP:-ai-assistant} volumes: - type: bind source: ${RATELDESK_MCP_CONFIG_FILE:?Set RATELDESK_MCP_CONFIG_FILE to a protected configuration file.} diff --git a/docker/docker-compose.mcp.yml b/docker/docker-compose.mcp.yml index cbb3b6e3..a0b83f0a 100644 --- a/docker/docker-compose.mcp.yml +++ b/docker/docker-compose.mcp.yml @@ -12,7 +12,11 @@ services: Helpdesk__Mcp__Instance: ${RATELDESK_MCP_INSTANCE:-local} Helpdesk__Mcp__ExpectedApiBaseUrl: http://api:8222/ Helpdesk__Mcp__PublicResourceUri: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical HTTPS MCP URL, for example https://mcp.example.test/mcp.} - Helpdesk__Mcp__AllowedOrigins: ${RATELDESK_MCP_ALLOWED_ORIGINS:-} + Helpdesk__Mcp__AllowedOrigins__0: ${RATELDESK_MCP_ALLOWED_ORIGIN:?Set the browser origin allowed to call this MCP endpoint.} + Authentication__AuthentikMcp__Authority: ${RATELDESK_MCP_AUTHENTIK_AUTHORITY:?Set the configured external authorization server URL.} + Authentication__AuthentikMcp__Audience: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical HTTPS MCP URL, for example https://mcp.example.test/mcp.} + Authentication__AuthentikMcp__RequiredScopes__0: ${RATELDESK_MCP_REQUIRED_SCOPE:-helpdesk.mcp} + Authentication__AuthentikMcp__RequiredGroups__0: ${RATELDESK_MCP_REQUIRED_GROUP:-ai-assistant} volumes: - type: bind source: ${RATELDESK_MCP_CONFIG_FILE:?Set RATELDESK_MCP_CONFIG_FILE to a protected configuration file.} diff --git a/docker/examples/mcp-http/.env.example b/docker/examples/mcp-http/.env.example index 725c567f..4d7bddf7 100644 --- a/docker/examples/mcp-http/.env.example +++ b/docker/examples/mcp-http/.env.example @@ -3,5 +3,9 @@ RATELDESK_VERSION=0.1.0-rc.9 RATELDESK_MCP_INSTANCE=example RATELDESK_API_BASE_URL=https://api.example.test/ RATELDESK_MCP_PUBLIC_RESOURCE_URI=https://mcp.example.test/mcp +RATELDESK_MCP_ALLOWED_ORIGIN=https://client.example.test +RATELDESK_MCP_AUTHENTIK_AUTHORITY=https://auth.example.test/application/o/rateldesk-mcp/ +RATELDESK_MCP_REQUIRED_SCOPE=helpdesk.mcp +RATELDESK_MCP_REQUIRED_GROUP=ai-assistant RATELDESK_MCP_CONFIG_FILE=./config.json RATELDESK_MCP_PORT=8223 diff --git a/docker/examples/mcp-http/README.md b/docker/examples/mcp-http/README.md index 13bfc367..1912b70f 100644 --- a/docker/examples/mcp-http/README.md +++ b/docker/examples/mcp-http/README.md @@ -1,9 +1,11 @@ # Optional HTTP MCP transport This example is an optional, stateless HTTP MCP transport for an existing -RatelDesk API. It does not start an API or a database. Create an MCP-targeted -integration credential after completing normal RatelDesk setup and sign-in, -then save it in `config.json` with owner-only permissions: +RatelDesk API. It does not start an API or a database. This external-mode +example requires the configured Authentik MCP authorization server, audience, +scope, and group settings in `.env`. The local-account paired gateway flow is +not represented by this container configuration yet; do not label an ordinary +API integration credential as an MCP credential. ```sh cp .env.example .env @@ -12,12 +14,12 @@ chmod 600 config.json docker compose up -d ``` -Set `RATELDESK_API_BASE_URL` to the exact API target and +Set `RATELDESK_API_BASE_URL` to the exact API target, `RATELDESK_MCP_PUBLIC_RESOURCE_URI` to the public HTTPS `/mcp` URL. Configure -the remote MCP client with that URL and its Bearer credential; local account -cookies are browser sessions, not MCP credentials. The MCP container receives -only its protected configuration file and does not mount the API database or -data-protection key ring. +`RATELDESK_MCP_ALLOWED_ORIGIN` to the exact browser origin (not a path), and +the `RATELDESK_MCP_AUTHENTIK_*` settings to the external MCP client contract. +The MCP container receives only its protected configuration file and does not +mount the API database or data-protection key ring. For a source checkout, run from the repository root: diff --git a/docker/examples/mcp-http/docker-compose.yml b/docker/examples/mcp-http/docker-compose.yml index d6575b87..46eed11f 100644 --- a/docker/examples/mcp-http/docker-compose.yml +++ b/docker/examples/mcp-http/docker-compose.yml @@ -10,7 +10,11 @@ services: Helpdesk__Mcp__Instance: ${RATELDESK_MCP_INSTANCE:-example} Helpdesk__Mcp__ExpectedApiBaseUrl: ${RATELDESK_API_BASE_URL:?Set the pinned API URL.} Helpdesk__Mcp__PublicResourceUri: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical HTTPS MCP URL.} - Helpdesk__Mcp__AllowedOrigins: ${RATELDESK_MCP_ALLOWED_ORIGINS:-} + Helpdesk__Mcp__AllowedOrigins__0: ${RATELDESK_MCP_ALLOWED_ORIGIN:?Set the browser origin allowed to call this MCP endpoint.} + Authentication__AuthentikMcp__Authority: ${RATELDESK_MCP_AUTHENTIK_AUTHORITY:?Set the configured external authorization server URL.} + Authentication__AuthentikMcp__Audience: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical HTTPS MCP URL.} + Authentication__AuthentikMcp__RequiredScopes__0: ${RATELDESK_MCP_REQUIRED_SCOPE:-helpdesk.mcp} + Authentication__AuthentikMcp__RequiredGroups__0: ${RATELDESK_MCP_REQUIRED_GROUP:-ai-assistant} volumes: - type: bind source: ${RATELDESK_MCP_CONFIG_FILE:?Set the protected MCP configuration file.} diff --git a/src/Helpdesk.Mcp.Http/Configuration/AuthentikMcpOptions.cs b/src/Helpdesk.Mcp.Http/Configuration/AuthentikMcpOptions.cs index 6e486691..29d2defc 100644 --- a/src/Helpdesk.Mcp.Http/Configuration/AuthentikMcpOptions.cs +++ b/src/Helpdesk.Mcp.Http/Configuration/AuthentikMcpOptions.cs @@ -47,5 +47,11 @@ private static void ValidateValues(string[] values, string name, ICollection Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps; internal static bool IsCanonicalMcpResource(string value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps && string.Equals(uri.AbsolutePath.TrimEnd('/'), "/mcp", StringComparison.Ordinal); + internal static bool IsAllowedOrigin(string value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) + && !string.IsNullOrWhiteSpace(uri.Host) + && (uri.AbsolutePath is "" or "/") + && string.IsNullOrEmpty(uri.Query) + && string.IsNullOrEmpty(uri.Fragment); internal static string Normalize(string value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri.AbsoluteUri.TrimEnd('/') : value; } diff --git a/src/Helpdesk.Mcp.Http/Program.cs b/src/Helpdesk.Mcp.Http/Program.cs index 3e2c5b7e..303380f3 100644 --- a/src/Helpdesk.Mcp.Http/Program.cs +++ b/src/Helpdesk.Mcp.Http/Program.cs @@ -40,6 +40,7 @@ public static void Main(string[] args) builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(HelpdeskMcpHttpOptions.SectionName)) .Validate(options => AuthentikMcpOptionsValidator.IsCanonicalMcpResource(options.PublicResourceUri), "Helpdesk:Mcp:PublicResourceUri must be an absolute HTTPS /mcp URI.") + .Validate(options => options.AllowedOrigins.Length > 0 && options.AllowedOrigins.All(AuthentikMcpOptionsValidator.IsAllowedOrigin), "Helpdesk:Mcp:AllowedOrigins must contain one or more absolute HTTP(S) origins without paths.") .ValidateOnStart(); builder.Services.AddSingleton, AuthentikMcpOptionsValidator>(); builder.Services.AddOptions() diff --git a/tests/Helpdesk.Tests/Mcp/AuthentikMcpOptionsValidatorTests.cs b/tests/Helpdesk.Tests/Mcp/AuthentikMcpOptionsValidatorTests.cs index e8e460af..509573e3 100644 --- a/tests/Helpdesk.Tests/Mcp/AuthentikMcpOptionsValidatorTests.cs +++ b/tests/Helpdesk.Tests/Mcp/AuthentikMcpOptionsValidatorTests.cs @@ -43,4 +43,17 @@ public void Rejects_an_audience_that_does_not_match_the_resource_identity() Assert.False(result.Succeeded); } + + [Theory] + [InlineData("https://client.example")] + [InlineData("http://localhost:3000")] + public void Accepts_a_single_origin_without_path(string origin) + => Assert.True(AuthentikMcpOptionsValidator.IsAllowedOrigin(origin)); + + [Theory] + [InlineData("https://client.example/mcp")] + [InlineData("https://client.example?query=value")] + [InlineData("not-an-origin")] + public void Rejects_an_origin_with_a_path_or_non_origin_value(string origin) + => Assert.False(AuthentikMcpOptionsValidator.IsAllowedOrigin(origin)); } From fbce57dac6fe7ebcd50e7447433480132bd5bb06 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 14:39:44 +0200 Subject: [PATCH 11/32] fix(mcp): validate configured origins in host tests --- src/Helpdesk.Mcp.Http/Configuration/AuthentikMcpOptions.cs | 7 ++++++- tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Helpdesk.Mcp.Http/Configuration/AuthentikMcpOptions.cs b/src/Helpdesk.Mcp.Http/Configuration/AuthentikMcpOptions.cs index 29d2defc..f586c743 100644 --- a/src/Helpdesk.Mcp.Http/Configuration/AuthentikMcpOptions.cs +++ b/src/Helpdesk.Mcp.Http/Configuration/AuthentikMcpOptions.cs @@ -47,7 +47,12 @@ private static void ValidateValues(string[] values, string name, ICollection Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps; internal static bool IsCanonicalMcpResource(string value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps && string.Equals(uri.AbsolutePath.TrimEnd('/'), "/mcp", StringComparison.Ordinal); - internal static bool IsAllowedOrigin(string value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) + /// + /// Determines whether a browser origin is a complete HTTP(S) origin rather + /// than a resource URL. Hosts use this to validate their allow-list before + /// accepting MCP browser requests. + /// + public static bool IsAllowedOrigin(string value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) && !string.IsNullOrWhiteSpace(uri.Host) && (uri.AbsolutePath is "" or "/") diff --git a/tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs b/tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs index ac2049fd..29c59fe9 100644 --- a/tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs +++ b/tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs @@ -517,6 +517,7 @@ private sealed class McpHostEnvironment : IDisposable private const string DevApiVariable = "RATELDESK_MCP_DEV_API_BASE_URL"; private const string ProdApiVariable = "RATELDESK_MCP_PROD_API_BASE_URL"; private const string PublicResourceVariable = "Helpdesk__Mcp__PublicResourceUri"; + private const string AllowedOriginVariable = "Helpdesk__Mcp__AllowedOrigins__0"; private const string AuthorityVariable = "Authentication__AuthentikMcp__Authority"; private const string AudienceVariable = "Authentication__AuthentikMcp__Audience"; private const string ScopeVariable = "Authentication__AuthentikMcp__RequiredScopes__0"; @@ -526,6 +527,7 @@ private sealed class McpHostEnvironment : IDisposable private readonly string? _previousDevApi = Environment.GetEnvironmentVariable(DevApiVariable); private readonly string? _previousProdApi = Environment.GetEnvironmentVariable(ProdApiVariable); private readonly string? _previousPublicResource = Environment.GetEnvironmentVariable(PublicResourceVariable); + private readonly string? _previousAllowedOrigin = Environment.GetEnvironmentVariable(AllowedOriginVariable); private readonly string? _previousAuthority = Environment.GetEnvironmentVariable(AuthorityVariable); private readonly string? _previousAudience = Environment.GetEnvironmentVariable(AudienceVariable); private readonly string? _previousScope = Environment.GetEnvironmentVariable(ScopeVariable); @@ -546,6 +548,7 @@ public McpHostEnvironment(string instance = "dev", string apiBaseUrl = "https:// Environment.SetEnvironmentVariable(InstanceVariable, instance); Environment.SetEnvironmentVariable(instance == "dev" ? DevApiVariable : ProdApiVariable, apiBaseUrl); Environment.SetEnvironmentVariable(PublicResourceVariable, publicResourceUri); + Environment.SetEnvironmentVariable(AllowedOriginVariable, "https://client.example"); Environment.SetEnvironmentVariable(AuthorityVariable, Authority); Environment.SetEnvironmentVariable(AudienceVariable, publicResourceUri); Environment.SetEnvironmentVariable(ScopeVariable, "helpdesk.mcp"); @@ -565,6 +568,7 @@ public void Dispose() Environment.SetEnvironmentVariable(DevApiVariable, _previousDevApi); Environment.SetEnvironmentVariable(ProdApiVariable, _previousProdApi); Environment.SetEnvironmentVariable(PublicResourceVariable, _previousPublicResource); + Environment.SetEnvironmentVariable(AllowedOriginVariable, _previousAllowedOrigin); Environment.SetEnvironmentVariable(AuthorityVariable, _previousAuthority); Environment.SetEnvironmentVariable(AudienceVariable, _previousAudience); Environment.SetEnvironmentVariable(ScopeVariable, _previousScope); From 199981e19760ea9fa883eb94a8c2e8e8bb351acd Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 14:58:54 +0200 Subject: [PATCH 12/32] fix(release): keep asset dry-run output deterministic --- tools/release/publish-release-assets.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/release/publish-release-assets.sh b/tools/release/publish-release-assets.sh index 61c35d96..c2c73349 100755 --- a/tools/release/publish-release-assets.sh +++ b/tools/release/publish-release-assets.sh @@ -32,8 +32,8 @@ assets+=("rateldesk-deployment-${version}.tar.gz" "SHA256SUMS" "release-manifest for asset in "${assets[@]}"; do [[ -s "$asset_directory/$asset" ]] || { echo "Expected release asset is missing or empty: $asset" >&2; exit 1; } done -(cd "$asset_directory" && sha256sum --check SHA256SUMS) -(cd "$asset_directory" && sha256sum --check release-manifest.json.sha256) +(cd "$asset_directory" && sha256sum --check --status SHA256SUMS) +(cd "$asset_directory" && sha256sum --check --status release-manifest.json.sha256) if [[ "${RELEASE_ASSET_DRY_RUN:-false}" == true ]]; then printf '%s\n' "${assets[@]}" From e4ed7099045dbc6337963f6a26be127f4c8d60e5 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 15:28:25 +0200 Subject: [PATCH 13/32] fix(mcp): pair local gateway credentials --- docker/docker-compose.mcp.release.yml | 1 + docker/docker-compose.mcp.yml | 1 + docker/examples/mcp-http/.env.example | 3 - docker/examples/mcp-http/README.md | 19 +- docker/examples/mcp-http/config.example.json | 3 +- docker/examples/mcp-http/docker-compose.yml | 5 +- ...egrationCredentialAuthenticationHandler.cs | 20 +- .../McpExecutionAuthenticationHandler.cs | 71 ++++ .../McpExecutionTokenService.cs | 59 +++ .../Documentation/RatelDeskOpenApiCatalog.cs | 12 + .../IntegrationCredentialEndpoints.cs | 49 ++- .../McpGatewayDelegationEndpoints.cs | 91 +++++ src/Helpdesk.API/Program.cs | 18 +- .../AgentClientConfiguration.cs | 4 +- .../HelpdeskAgentClient.cs | 17 +- ...tegrationCredentialMcpResource.Designer.cs | 363 +++++++++++++++++ ...346_AddIntegrationCredentialMcpResource.cs | 29 ++ ...RatelDeskIdentityDbContextModelSnapshot.cs | 4 + .../Identity/IntegrationCredential.cs | 6 + ...tegrationCredentialMcpResource.Designer.cs | 372 ++++++++++++++++++ ...509_AddIntegrationCredentialMcpResource.cs | 29 ++ ...RatelDeskIdentityDbContextModelSnapshot.cs | 4 + .../Identity/RatelDeskIdentityDbContext.cs | 1 + .../GatewayDelegationAuthenticationHandler.cs | 104 +++++ .../Configuration/AuthentikMcpOptions.cs | 3 + src/Helpdesk.Mcp.Http/Program.cs | 97 +++-- .../Api/IntegrationCredentialSqliteTests.cs | 43 +- .../Api/McpGatewayDelegationEndpointTests.cs | 168 ++++++++ .../Api/OpenApiAndVersionEndpointsTests.cs | 2 + tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs | 106 ++++- 30 files changed, 1639 insertions(+), 65 deletions(-) create mode 100644 src/Helpdesk.API/Authentication/McpExecutionAuthenticationHandler.cs create mode 100644 src/Helpdesk.API/Authentication/McpExecutionTokenService.cs create mode 100644 src/Helpdesk.API/Endpoints/Authentication/McpGatewayDelegationEndpoints.cs create mode 100644 src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917124346_AddIntegrationCredentialMcpResource.Designer.cs create mode 100644 src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917124346_AddIntegrationCredentialMcpResource.cs create mode 100644 src/Helpdesk.Infrastructure/Identity/Migrations/20260917124509_AddIntegrationCredentialMcpResource.Designer.cs create mode 100644 src/Helpdesk.Infrastructure/Identity/Migrations/20260917124509_AddIntegrationCredentialMcpResource.cs create mode 100644 src/Helpdesk.Mcp.Http/Authorization/GatewayDelegationAuthenticationHandler.cs create mode 100644 tests/Helpdesk.Tests/Api/McpGatewayDelegationEndpointTests.cs diff --git a/docker/docker-compose.mcp.release.yml b/docker/docker-compose.mcp.release.yml index 028315fe..d55d20bb 100644 --- a/docker/docker-compose.mcp.release.yml +++ b/docker/docker-compose.mcp.release.yml @@ -11,6 +11,7 @@ services: Helpdesk__Mcp__ExpectedApiBaseUrl: http://api:8222/ Helpdesk__Mcp__PublicResourceUri: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical HTTPS MCP URL, for example https://mcp.example.test/mcp.} Helpdesk__Mcp__AllowedOrigins__0: ${RATELDESK_MCP_ALLOWED_ORIGIN:?Set the browser origin allowed to call this MCP endpoint.} + Helpdesk__Mcp__AuthenticationMode: authentik Authentication__AuthentikMcp__Authority: ${RATELDESK_MCP_AUTHENTIK_AUTHORITY:?Set the configured external authorization server URL.} Authentication__AuthentikMcp__Audience: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical HTTPS MCP URL, for example https://mcp.example.test/mcp.} Authentication__AuthentikMcp__RequiredScopes__0: ${RATELDESK_MCP_REQUIRED_SCOPE:-helpdesk.mcp} diff --git a/docker/docker-compose.mcp.yml b/docker/docker-compose.mcp.yml index a0b83f0a..648e2d43 100644 --- a/docker/docker-compose.mcp.yml +++ b/docker/docker-compose.mcp.yml @@ -13,6 +13,7 @@ services: Helpdesk__Mcp__ExpectedApiBaseUrl: http://api:8222/ Helpdesk__Mcp__PublicResourceUri: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical HTTPS MCP URL, for example https://mcp.example.test/mcp.} Helpdesk__Mcp__AllowedOrigins__0: ${RATELDESK_MCP_ALLOWED_ORIGIN:?Set the browser origin allowed to call this MCP endpoint.} + Helpdesk__Mcp__AuthenticationMode: authentik Authentication__AuthentikMcp__Authority: ${RATELDESK_MCP_AUTHENTIK_AUTHORITY:?Set the configured external authorization server URL.} Authentication__AuthentikMcp__Audience: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical HTTPS MCP URL, for example https://mcp.example.test/mcp.} Authentication__AuthentikMcp__RequiredScopes__0: ${RATELDESK_MCP_REQUIRED_SCOPE:-helpdesk.mcp} diff --git a/docker/examples/mcp-http/.env.example b/docker/examples/mcp-http/.env.example index 4d7bddf7..ec799dc5 100644 --- a/docker/examples/mcp-http/.env.example +++ b/docker/examples/mcp-http/.env.example @@ -4,8 +4,5 @@ RATELDESK_MCP_INSTANCE=example RATELDESK_API_BASE_URL=https://api.example.test/ RATELDESK_MCP_PUBLIC_RESOURCE_URI=https://mcp.example.test/mcp RATELDESK_MCP_ALLOWED_ORIGIN=https://client.example.test -RATELDESK_MCP_AUTHENTIK_AUTHORITY=https://auth.example.test/application/o/rateldesk-mcp/ -RATELDESK_MCP_REQUIRED_SCOPE=helpdesk.mcp -RATELDESK_MCP_REQUIRED_GROUP=ai-assistant RATELDESK_MCP_CONFIG_FILE=./config.json RATELDESK_MCP_PORT=8223 diff --git a/docker/examples/mcp-http/README.md b/docker/examples/mcp-http/README.md index 1912b70f..0868f2d3 100644 --- a/docker/examples/mcp-http/README.md +++ b/docker/examples/mcp-http/README.md @@ -1,11 +1,12 @@ # Optional HTTP MCP transport This example is an optional, stateless HTTP MCP transport for an existing -RatelDesk API. It does not start an API or a database. This external-mode -example requires the configured Authentik MCP authorization server, audience, -scope, and group settings in `.env`. The local-account paired gateway flow is -not represented by this container configuration yet; do not label an ordinary -API integration credential as an MCP credential. +RatelDesk API. It does not start an API or a database. This example uses the +local-account paired gateway flow. Configure a local MCP +credential in RatelDesk for this exact public resource URI, then manually +configure that credential as a Bearer token in the MCP client. The gateway +exchanges it for a request-scoped, short-lived execution credential; it never +forwards the credential to ordinary API operations. ```sh cp .env.example .env @@ -16,11 +17,15 @@ docker compose up -d Set `RATELDESK_API_BASE_URL` to the exact API target, `RATELDESK_MCP_PUBLIC_RESOURCE_URI` to the public HTTPS `/mcp` URL. Configure -`RATELDESK_MCP_ALLOWED_ORIGIN` to the exact browser origin (not a path), and -the `RATELDESK_MCP_AUTHENTIK_*` settings to the external MCP client contract. +`RATELDESK_MCP_ALLOWED_ORIGIN` to the exact browser origin (not a path). The MCP container receives only its protected configuration file and does not mount the API database or data-protection key ring. +For an external OAuth deployment, use the source or release compose overlay +with `Helpdesk__Mcp__AuthenticationMode=authentik` and its required +`Authentication__AuthentikMcp__*` settings. Authentik mode is explicit and is +never used as a fallback for local paired credentials. + For a source checkout, run from the repository root: ```sh diff --git a/docker/examples/mcp-http/config.example.json b/docker/examples/mcp-http/config.example.json index 4ae19dfb..b18641e5 100644 --- a/docker/examples/mcp-http/config.example.json +++ b/docker/examples/mcp-http/config.example.json @@ -1,5 +1,4 @@ { "apiBaseUrl": "https://api.example.test/", - "credentialMode": "integration", - "integrationCredential": "replace-with-an-mcp-targeted-credential" + "credentialMode": "gateway" } diff --git a/docker/examples/mcp-http/docker-compose.yml b/docker/examples/mcp-http/docker-compose.yml index 46eed11f..f78cba1f 100644 --- a/docker/examples/mcp-http/docker-compose.yml +++ b/docker/examples/mcp-http/docker-compose.yml @@ -11,10 +11,7 @@ services: Helpdesk__Mcp__ExpectedApiBaseUrl: ${RATELDESK_API_BASE_URL:?Set the pinned API URL.} Helpdesk__Mcp__PublicResourceUri: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical HTTPS MCP URL.} Helpdesk__Mcp__AllowedOrigins__0: ${RATELDESK_MCP_ALLOWED_ORIGIN:?Set the browser origin allowed to call this MCP endpoint.} - Authentication__AuthentikMcp__Authority: ${RATELDESK_MCP_AUTHENTIK_AUTHORITY:?Set the configured external authorization server URL.} - Authentication__AuthentikMcp__Audience: ${RATELDESK_MCP_PUBLIC_RESOURCE_URI:?Set the canonical HTTPS MCP URL.} - Authentication__AuthentikMcp__RequiredScopes__0: ${RATELDESK_MCP_REQUIRED_SCOPE:-helpdesk.mcp} - Authentication__AuthentikMcp__RequiredGroups__0: ${RATELDESK_MCP_REQUIRED_GROUP:-ai-assistant} + Helpdesk__Mcp__AuthenticationMode: gateway volumes: - type: bind source: ${RATELDESK_MCP_CONFIG_FILE:?Set the protected MCP configuration file.} diff --git a/src/Helpdesk.API/Authentication/IntegrationCredentialAuthenticationHandler.cs b/src/Helpdesk.API/Authentication/IntegrationCredentialAuthenticationHandler.cs index 6c6a2e54..5b278e24 100644 --- a/src/Helpdesk.API/Authentication/IntegrationCredentialAuthenticationHandler.cs +++ b/src/Helpdesk.API/Authentication/IntegrationCredentialAuthenticationHandler.cs @@ -8,14 +8,20 @@ namespace Helpdesk.API.Authentication; +public sealed class IntegrationCredentialAuthenticationOptions : AuthenticationSchemeOptions +{ + public string Purpose { get; set; } = IntegrationCredentialAuthenticationHandler.ApiPurpose; +} + public sealed class IntegrationCredentialAuthenticationHandler( - IOptionsMonitor options, + IOptionsMonitor options, ILoggerFactory logger, System.Text.Encodings.Web.UrlEncoder encoder, RatelDeskIdentityDbContext identityDb) - : AuthenticationHandler(options, logger, encoder) + : AuthenticationHandler(options, logger, encoder) { public const string SchemeName = "IntegrationCredential"; + public const string McpSchemeName = "McpIntegrationCredential"; public const string ApiPurpose = "api"; public const string McpPurpose = "mcp"; @@ -36,7 +42,7 @@ protected override async Task HandleAuthenticateAsync() if (credential is null || credential.RevokedAtUtc is not null || credential.ExpiresAtUtc <= DateTimeOffset.UtcNow || - !string.Equals(credential.Purpose, ApiPurpose, StringComparison.Ordinal)) + !string.Equals(credential.Purpose, ExpectedPurpose, StringComparison.Ordinal)) { return AuthenticateResult.Fail("The integration credential is not valid for this API."); } @@ -66,10 +72,14 @@ credential.RevokedAtUtc is not null || }; if (!string.IsNullOrWhiteSpace(credential.OrganizationId)) claims.Add(new Claim("integration_organization_id", credential.OrganizationId)); + if (!string.IsNullOrWhiteSpace(credential.McpResourceUri)) + claims.Add(new Claim("mcp_resource_uri", credential.McpResourceUri)); foreach (var permission in credential.Permissions.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) claims.Add(new Claim("integration_permission", permission)); - var identity = new ClaimsIdentity(claims, SchemeName, ClaimTypes.Name, ClaimTypes.Role); - return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName)); + var identity = new ClaimsIdentity(claims, Scheme.Name, ClaimTypes.Name, ClaimTypes.Role); + return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)); } + + private string ExpectedPurpose => Options.Purpose; } diff --git a/src/Helpdesk.API/Authentication/McpExecutionAuthenticationHandler.cs b/src/Helpdesk.API/Authentication/McpExecutionAuthenticationHandler.cs new file mode 100644 index 00000000..03911fdb --- /dev/null +++ b/src/Helpdesk.API/Authentication/McpExecutionAuthenticationHandler.cs @@ -0,0 +1,71 @@ +using System.Security.Claims; +using Helpdesk.Infrastructure.Identity; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Helpdesk.API.Authentication; + +/// +/// Validates a gateway-delegated execution token. It always reloads the +/// source credential and account so revocation, expiry, account disablement, +/// and the current access projection take effect on the next API request. +/// +public sealed class McpExecutionAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + System.Text.Encodings.Web.UrlEncoder encoder, + McpExecutionTokenService executionTokens, + RatelDeskIdentityDbContext identityDb) + : AuthenticationHandler(options, logger, encoder) +{ + public const string SchemeName = "McpExecution"; + + protected override async Task HandleAuthenticateAsync() + { + var authorization = Request.Headers.Authorization.ToString(); + if (!authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return AuthenticateResult.NoResult(); + + var token = authorization["Bearer ".Length..].Trim(); + if (!executionTokens.TryRead(token, out var payload) || payload is null) + return AuthenticateResult.Fail("The delegated MCP execution credential is invalid."); + + var credential = await identityDb.IntegrationCredentials + .AsNoTracking() + .SingleOrDefaultAsync(candidate => candidate.Id == payload.CredentialId, Context.RequestAborted) + .ConfigureAwait(false); + if (credential is null || credential.RevokedAtUtc is not null || credential.ExpiresAtUtc <= DateTimeOffset.UtcNow || + !string.Equals(credential.Purpose, IntegrationCredentialAuthenticationHandler.McpPurpose, StringComparison.Ordinal) || + !string.Equals(credential.OwnerUserId, payload.OwnerUserId, StringComparison.Ordinal) || + !string.Equals(credential.McpResourceUri, payload.ResourceUri, StringComparison.Ordinal)) + { + return AuthenticateResult.Fail("The delegated MCP execution credential is no longer valid."); + } + + var owner = await identityDb.Users.AsNoTracking() + .SingleOrDefaultAsync(user => user.Id == credential.OwnerUserId, Context.RequestAborted) + .ConfigureAwait(false); + if (owner?.IsEnabled != true) + return AuthenticateResult.Fail("The delegated MCP execution credential owner is disabled."); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, owner.Id), + new(ClaimTypes.Name, string.IsNullOrWhiteSpace(owner.DisplayName) ? owner.UserName ?? owner.Email ?? owner.Id : owner.DisplayName), + new(ClaimTypes.Email, owner.Email ?? string.Empty), + new("auth_mode", "integration"), + new("token_use", "mcp_execution"), + new("integration_credential_id", credential.Id.ToString("N")), + new("integration_purpose", credential.Purpose), + new("mcp_resource_uri", credential.McpResourceUri ?? string.Empty) + }; + if (!string.IsNullOrWhiteSpace(credential.OrganizationId)) + claims.Add(new Claim("integration_organization_id", credential.OrganizationId)); + foreach (var permission in credential.Permissions.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + claims.Add(new Claim("integration_permission", permission)); + + var identity = new ClaimsIdentity(claims, SchemeName, ClaimTypes.Name, ClaimTypes.Role); + return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName)); + } +} diff --git a/src/Helpdesk.API/Authentication/McpExecutionTokenService.cs b/src/Helpdesk.API/Authentication/McpExecutionTokenService.cs new file mode 100644 index 00000000..3bbfba91 --- /dev/null +++ b/src/Helpdesk.API/Authentication/McpExecutionTokenService.cs @@ -0,0 +1,59 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json; +using Helpdesk.Infrastructure.Identity; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.WebUtilities; + +namespace Helpdesk.API.Authentication; + +/// +/// Creates API-only, short-lived execution credentials after an HTTP MCP +/// gateway has authenticated an MCP-purpose integration credential. The opaque +/// value is protected by the API key ring, so a gateway can forward it but can +/// neither mint nor inspect it. +/// +public sealed class McpExecutionTokenService(IDataProtectionProvider protection) +{ + public const string TokenPrefix = "rdx_"; + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + private readonly IDataProtector _protector = protection.CreateProtector("RatelDesk.Mcp.ExecutionCredential.v1"); + + public string Create(IntegrationCredential credential, ApplicationUser owner, string resourceUri, DateTimeOffset expiresAtUtc) + { + ArgumentException.ThrowIfNullOrWhiteSpace(resourceUri); + var payload = new McpExecutionTokenPayload( + credential.Id, + owner.Id, + resourceUri, + expiresAtUtc, + Guid.NewGuid().ToString("N")); + var protectedPayload = _protector.Protect(JsonSerializer.SerializeToUtf8Bytes(payload, SerializerOptions)); + return TokenPrefix + WebEncoders.Base64UrlEncode(protectedPayload); + } + + public bool TryRead(string? token, out McpExecutionTokenPayload? payload) + { + payload = null; + if (string.IsNullOrWhiteSpace(token) || !token.StartsWith(TokenPrefix, StringComparison.Ordinal)) + return false; + + try + { + var protectedPayload = WebEncoders.Base64UrlDecode(token[TokenPrefix.Length..]); + payload = JsonSerializer.Deserialize(_protector.Unprotect(protectedPayload), SerializerOptions); + return payload is not null && payload.ExpiresAtUtc > DateTimeOffset.UtcNow; + } + catch (Exception exception) when (exception is CryptographicException or FormatException or JsonException) + { + return false; + } + } +} + +public sealed record McpExecutionTokenPayload( + Guid CredentialId, + string OwnerUserId, + string ResourceUri, + DateTimeOffset ExpiresAtUtc, + string TokenId); diff --git a/src/Helpdesk.API/Documentation/RatelDeskOpenApiCatalog.cs b/src/Helpdesk.API/Documentation/RatelDeskOpenApiCatalog.cs index 9091c4f4..ba35b20a 100644 --- a/src/Helpdesk.API/Documentation/RatelDeskOpenApiCatalog.cs +++ b/src/Helpdesk.API/Documentation/RatelDeskOpenApiCatalog.cs @@ -12,6 +12,7 @@ public static class RatelDeskOpenApiCatalog { private const string JwtBearerScheme = "JwtBearer"; private const string IntegrationCredentialScheme = "IntegrationCredential"; + private const string McpIntegrationCredentialScheme = "McpIntegrationCredential"; private const string LocalSessionScheme = "LocalSession"; private const string AiAgentScheme = "AiAgentJwt"; private const string OrchestrationScheme = "OrchestrationM2M"; @@ -28,6 +29,7 @@ private sealed record Tag(string Name, string Group, string Description); new("Users", "Identity & Access", "Application users and access."), new("Role Definitions", "Identity & Access", "Scoped application role definitions."), new("Integration Credentials", "Identity & Access", "Revocable API credentials. Secrets are shown once."), + new("MCP Gateway", "Automation & Integrations", "Paired MCP credential delegation."), new("Organizations", "Organizations & Customers", "Application tenant organizations."), new("Customers", "Organizations & Customers", "Contacts; a customer is not the application tenant."), new("Tenant Administration", "Organizations & Customers", "Tenant-scoped administration."), @@ -93,6 +95,15 @@ public static void ConfigureSecuritySchemes(OpenApiDocument document, string loc In = ParameterLocation.Header, Description = "Opaque integration credential: `Bearer rdk__`. It is not a JWT and is scoped to its organization and permissions." }; + document.Components.SecuritySchemes[McpIntegrationCredentialScheme] = new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "paired opaque rdk credential", + In = ParameterLocation.Header, + Description = "Paired MCP credential: `Bearer rdk__`. It is valid only for the MCP execution-token exchange and cannot call ordinary API operations." + }; document.Components.SecuritySchemes[LocalSessionScheme] = new OpenApiSecurityScheme { Name = localCookieName, @@ -164,6 +175,7 @@ _ when policies.Contains("AuthentikAiAgentApi") => Requirements(document, AiAgen _ when policies.Contains("OrchestrationM2MOnly") => Requirements(document, OrchestrationScheme), _ when policies.Contains("SystemBlazorWeb") => Requirements(document, SystemScheme), _ when policies.Contains("IntegrationCredentialManagementSession") => Requirements(document, JwtBearerScheme, LocalSessionScheme), + _ when policies.Contains("McpCredentialDelegation") => Requirements(document, McpIntegrationCredentialScheme), _ => Requirements(document, JwtBearerScheme, IntegrationCredentialScheme, LocalSessionScheme) }; return Task.CompletedTask; diff --git a/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs b/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs index 7772519c..ac868de9 100644 --- a/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs +++ b/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs @@ -33,7 +33,10 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder .Select(credential => new IntegrationCredentialMetadata( credential.Id, credential.Name, credential.Prefix, credential.Purpose, credential.OrganizationId, credential.Permissions.Split(' ', StringSplitOptions.RemoveEmptyEntries), - credential.ExpiresAtUtc, credential.CreatedAtUtc, credential.LastUsedAtUtc, credential.RevokedAtUtc)) + credential.ExpiresAtUtc, credential.CreatedAtUtc, credential.LastUsedAtUtc, credential.RevokedAtUtc) + { + McpResourceUri = credential.McpResourceUri + }) .ToListAsync(ct); return Results.Ok(credentials); }).WithSummary("List integration credentials"); @@ -52,7 +55,13 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder if (request is null) return Results.ValidationProblem(new Dictionary { ["request"] = ["A credential request is required."] }); if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 128) return Results.ValidationProblem(new Dictionary { ["name"] = ["A credential name up to 128 characters is required."] }); if (request.Purpose is not ("api" or "mcp")) return Results.ValidationProblem(new Dictionary { ["purpose"] = ["Purpose must be api or mcp."] }); - if (request.Purpose == "mcp") return Results.ValidationProblem(new Dictionary { ["purpose"] = ["MCP credentials are created through the paired HTTP MCP gateway configuration flow."] }); + var mcpResourceUri = request.Purpose == "mcp" + ? CanonicalMcpResourceUri(request.McpResourceUri) + : null; + if (request.Purpose == "mcp" && mcpResourceUri is null) + return Results.ValidationProblem(new Dictionary { ["mcpResourceUri"] = ["MCP credentials require an absolute HTTPS resource URI ending in /mcp."] }); + if (request.Purpose == "api" && !string.IsNullOrWhiteSpace(request.McpResourceUri)) + return Results.ValidationProblem(new Dictionary { ["mcpResourceUri"] = ["MCP resource URIs can only be configured for MCP credentials."] }); var access = await accessService.ResolveAsync(principal, ct); var requestedPermissions = request.Permissions? @@ -80,6 +89,7 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder Prefix = prefix, SecretHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(secret))), Purpose = request.Purpose, + McpResourceUri = mcpResourceUri, OrganizationId = request.OrganizationId, Permissions = string.Join(' ', requestedPermissions.Order(StringComparer.OrdinalIgnoreCase)), CreatedAtUtc = createdAtUtc, @@ -91,7 +101,10 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder context.Response.Headers.CacheControl = "no-store"; return Results.Created($"/api/v1/integration-credentials/{credential.Id:N}", new CreatedIntegrationCredential( credential.Id, credential.Prefix, $"rdk_{credential.Id:N}_{secret}", credential.Purpose, - credential.OrganizationId, requestedPermissions, credential.ExpiresAtUtc)); + credential.OrganizationId, requestedPermissions, credential.ExpiresAtUtc) + { + McpResourceUri = credential.McpResourceUri + }); }).WithSummary("Create an API integration credential"); group.MapDelete("/{credentialId:guid}", async (Guid credentialId, ClaimsPrincipal principal, IIntegrationCredentialOwnerResolver ownerResolver, RatelDeskIdentityDbContext identityDb, CancellationToken ct) => @@ -109,7 +122,31 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder }).WithSummary("Revoke an integration credential"); } - public sealed record CreateIntegrationCredentialRequest(string Name, string Purpose, string OrganizationId, IReadOnlyList Permissions, int? LifetimeDays); - public sealed record IntegrationCredentialMetadata(Guid Id, string Name, string Prefix, string Purpose, string? OrganizationId, IReadOnlyList Permissions, DateTimeOffset ExpiresAtUtc, DateTimeOffset CreatedAtUtc, DateTimeOffset? LastUsedAtUtc, DateTimeOffset? RevokedAtUtc); - public sealed record CreatedIntegrationCredential(Guid Id, string Prefix, string Secret, string Purpose, string? OrganizationId, IReadOnlyList Permissions, DateTimeOffset ExpiresAtUtc); + private static string? CanonicalMcpResourceUri(string? value) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + !string.IsNullOrEmpty(uri.UserInfo) || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || + !string.Equals(uri.AbsolutePath.TrimEnd('/'), "/mcp", StringComparison.Ordinal)) + { + return null; + } + + return uri.AbsoluteUri.TrimEnd('/'); + } + + public sealed record CreateIntegrationCredentialRequest(string Name, string Purpose, string OrganizationId, IReadOnlyList Permissions, int? LifetimeDays) + { + public string? McpResourceUri { get; init; } + } + + public sealed record IntegrationCredentialMetadata(Guid Id, string Name, string Prefix, string Purpose, string? OrganizationId, IReadOnlyList Permissions, DateTimeOffset ExpiresAtUtc, DateTimeOffset CreatedAtUtc, DateTimeOffset? LastUsedAtUtc, DateTimeOffset? RevokedAtUtc) + { + public string? McpResourceUri { get; init; } + } + + public sealed record CreatedIntegrationCredential(Guid Id, string Prefix, string Secret, string Purpose, string? OrganizationId, IReadOnlyList Permissions, DateTimeOffset ExpiresAtUtc) + { + public string? McpResourceUri { get; init; } + } } diff --git a/src/Helpdesk.API/Endpoints/Authentication/McpGatewayDelegationEndpoints.cs b/src/Helpdesk.API/Endpoints/Authentication/McpGatewayDelegationEndpoints.cs new file mode 100644 index 00000000..51df677d --- /dev/null +++ b/src/Helpdesk.API/Endpoints/Authentication/McpGatewayDelegationEndpoints.cs @@ -0,0 +1,91 @@ +using System.Security.Claims; +using Helpdesk.API.Authentication; +using Helpdesk.Infrastructure.Identity; +using Helpdesk.Shared.Services; +using Microsoft.EntityFrameworkCore; + +namespace Helpdesk.API.Endpoints.Authentication; + +/// +/// The only API endpoint that accepts an MCP-purpose opaque credential. It +/// exchanges that gateway credential for a short-lived execution credential +/// after resolving the owner's current authorization at the API authority. +/// +public static class McpGatewayDelegationEndpoints +{ + public const string DelegationPolicy = "McpCredentialDelegation"; + public const string ResourceHeader = "X-RatelDesk-Mcp-Resource"; + private static readonly TimeSpan ExecutionLifetime = TimeSpan.FromMinutes(2); + + public static void MapMcpGatewayDelegationEndpoints(this IEndpointRouteBuilder app) + { + app.MapPost("/api/v1/mcp/execution-token", async ( + HttpContext context, + ClaimsPrincipal principal, + RatelDeskIdentityDbContext identityDb, + ICurrentUserAccessService accessService, + McpExecutionTokenService executionTokens, + CancellationToken ct) => + { + var resourceUri = CanonicalResourceUri(context.Request.Headers[ResourceHeader].ToString()); + var credentialId = principal.FindFirstValue("integration_credential_id"); + if (resourceUri is null || !Guid.TryParseExact(credentialId, "N", out var parsedCredentialId)) + return Results.Unauthorized(); + + var credential = await identityDb.IntegrationCredentials + .AsNoTracking() + .SingleOrDefaultAsync(candidate => candidate.Id == parsedCredentialId, ct) + .ConfigureAwait(false); + if (credential is null || !string.Equals(credential.McpResourceUri, resourceUri, StringComparison.Ordinal)) + return Results.Forbid(); + + var owner = await identityDb.Users.AsNoTracking() + .SingleOrDefaultAsync(user => user.Id == credential.OwnerUserId, ct) + .ConfigureAwait(false); + if (owner?.IsEnabled != true) + return Results.Unauthorized(); + + var access = await accessService.ResolveAsync(principal, ct).ConfigureAwait(false); + if (!access.IsAuthenticated || access.Permissions.Count == 0 || access.AllowedOrganizationIds.Count == 0) + return Results.Forbid(); + + var expiresAtUtc = DateTimeOffset.UtcNow.Add(ExecutionLifetime); + var token = executionTokens.Create(credential, owner, resourceUri, expiresAtUtc); + context.Response.Headers.CacheControl = "no-store"; + return Results.Ok(new McpExecutionTokenResponse( + token, + expiresAtUtc, + owner.Id, + owner.DisplayName ?? owner.UserName ?? owner.Email ?? owner.Id, + access.PrimaryOrganizationId, + access.Permissions.Order(StringComparer.OrdinalIgnoreCase).ToArray(), + access.AllowedOrganizationIds.Order(StringComparer.OrdinalIgnoreCase).ToArray())); + }) + .RequireAuthorization(DelegationPolicy) + .WithTags("MCP Gateway") + .WithSummary("Exchanges a paired MCP credential for a short-lived execution credential.") + .WithDescription("Only paired MCP credentials can call this endpoint. The resulting execution credential is API-only and is never valid at the MCP gateway."); + } + + public static string? CanonicalResourceUri(string? value) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + !string.IsNullOrEmpty(uri.UserInfo) || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || + !string.Equals(uri.AbsolutePath.TrimEnd('/'), "/mcp", StringComparison.Ordinal)) + { + return null; + } + + return uri.AbsoluteUri.TrimEnd('/'); + } + + public sealed record McpExecutionTokenResponse( + string AccessToken, + DateTimeOffset ExpiresAtUtc, + string UserId, + string Name, + string? PrimaryOrganizationId, + IReadOnlyList Permissions, + IReadOnlyList AllowedOrganizationIds); +} diff --git a/src/Helpdesk.API/Program.cs b/src/Helpdesk.API/Program.cs index 8f811967..6642f37f 100644 --- a/src/Helpdesk.API/Program.cs +++ b/src/Helpdesk.API/Program.cs @@ -527,6 +527,8 @@ await BootstrapStartupService.ReconcileSelectedMarkerAsync( var token = auth.Substring("Bearer ".Length).Trim(); if (token.StartsWith("rdk_", StringComparison.OrdinalIgnoreCase)) return IntegrationCredentialAuthenticationHandler.SchemeName; + if (token.StartsWith(McpExecutionTokenService.TokenPrefix, StringComparison.OrdinalIgnoreCase)) + return McpExecutionAuthenticationHandler.SchemeName; try { var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); @@ -564,8 +566,14 @@ await BootstrapStartupService.ReconcileSelectedMarkerAsync( return "Azure"; }; }) -.AddScheme( +.AddScheme( IntegrationCredentialAuthenticationHandler.SchemeName, + options => options.Purpose = IntegrationCredentialAuthenticationHandler.ApiPurpose) +.AddScheme( + IntegrationCredentialAuthenticationHandler.McpSchemeName, + options => options.Purpose = IntegrationCredentialAuthenticationHandler.McpPurpose) +.AddScheme( + McpExecutionAuthenticationHandler.SchemeName, _ => { }) .AddCookie(LocalAuthenticationOptions.Scheme, options => { @@ -945,6 +953,12 @@ await OrchestrationCallbackEndpoints.PublishRejectedAsync( }); builder.Services.AddAuthorization(opts => { + opts.AddPolicy(McpGatewayDelegationEndpoints.DelegationPolicy, policy => + { + policy.AddAuthenticationSchemes(IntegrationCredentialAuthenticationHandler.McpSchemeName); + policy.RequireAuthenticatedUser(); + }); + opts.AddPolicy(IntegrationCredentialEndpoints.CredentialManagementPolicy, policy => { policy.RequireAuthenticatedUser(); @@ -1034,6 +1048,7 @@ await OrchestrationCallbackEndpoints.PublishRejectedAsync( builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Logging.AddFilter("Microsoft.AspNetCore.Authentication", LogLevel.Debug); @@ -1163,6 +1178,7 @@ IResult WriteDebugLog(ILoggerFactory loggerFactory) app.MapCurrentUserAccessEndpoint(); app.MapIntegrationCredentialEndpoints(); +app.MapMcpGatewayDelegationEndpoints(); app.MapGet("/api/v1/setup/status", () => Results.Ok(new { state = "Ready" })) .AllowAnonymous() .WithTags("Setup"); diff --git a/src/Helpdesk.AgentClient/AgentClientConfiguration.cs b/src/Helpdesk.AgentClient/AgentClientConfiguration.cs index 4087960c..7c6c67a6 100644 --- a/src/Helpdesk.AgentClient/AgentClientConfiguration.cs +++ b/src/Helpdesk.AgentClient/AgentClientConfiguration.cs @@ -46,7 +46,9 @@ public ResolvedAgentClientConfiguration Resolve() var mode = string.IsNullOrWhiteSpace(CredentialMode) ? "authentik" : CredentialMode.Trim().ToLowerInvariant(); if (mode == "integration") return new(new Uri(string.IsNullOrWhiteSpace(ApiBaseUrl) ? DefaultApiBaseUrl : ApiBaseUrl), null, null, null, Required(IntegrationCredential, "RATELDESK_INTEGRATION_CREDENTIAL"), null, AgentUserEmail, mode, IntegrationCredential); - if (mode != "authentik") throw new AgentClientValidationException("credentialMode must be authentik or integration."); + if (mode == "gateway") + return new(new Uri(Required(ApiBaseUrl, "RATELDESK_API_BASE_URL")), null, null, null, null, null, AgentUserEmail, mode, null); + if (mode != "authentik") throw new AgentClientValidationException("credentialMode must be authentik, integration, or gateway."); return new(new Uri(string.IsNullOrWhiteSpace(ApiBaseUrl) ? DefaultApiBaseUrl : ApiBaseUrl), Required(AuthentikTokenUrl, "RATELDESK_AUTHENTIK_TOKEN_URL"), Required(AuthentikClientId, "RATELDESK_AUTHENTIK_CLIENT_ID"), Required(AuthentikUsername, "RATELDESK_AUTHENTIK_USERNAME"), Required(AuthentikAppPassword, "RATELDESK_AUTHENTIK_APP_PASSWORD"), string.IsNullOrWhiteSpace(AuthentikScope) ? DefaultScope : AuthentikScope, AgentUserEmail, mode, null); } diff --git a/src/Helpdesk.AgentClient/HelpdeskAgentClient.cs b/src/Helpdesk.AgentClient/HelpdeskAgentClient.cs index d051fa03..aef05a72 100644 --- a/src/Helpdesk.AgentClient/HelpdeskAgentClient.cs +++ b/src/Helpdesk.AgentClient/HelpdeskAgentClient.cs @@ -15,6 +15,16 @@ public interface IHelpdeskAgentClient Task GetHealthAsync(CancellationToken cancellationToken = default); } +/// +/// Supplies an API access token for a transport that has already authenticated +/// the current caller. Implementations must be request-scoped in behavior; +/// they must not retain an inbound caller credential in a shared client. +/// +public interface IAgentAccessTokenProvider +{ + Task GetAccessTokenAsync(CancellationToken cancellationToken = default); +} + /// /// Calls the Helpdesk API using immutable, deployment-owned configuration. /// It never inspects inbound HTTP context or forwards inbound credentials. @@ -31,6 +41,7 @@ public sealed class HelpdeskAgentClient : IHelpdeskAgentClient private readonly Func? _handlerFactory; private readonly IHttpClientFactory? _httpClientFactory; private readonly CancellationToken _applicationStopping; + private readonly IAgentAccessTokenProvider? _accessTokenProvider; private readonly object _tokenRefreshLock = new(); private TokenCacheEntry? _tokenCache; private Task? _tokenRefresh; @@ -44,11 +55,13 @@ public HelpdeskAgentClient(AgentClientConfiguration configuration, Func GetHealthAsync(CancellationToken cancellationToken public async Task GetAccessTokenAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + if (_accessTokenProvider is not null) + return await _accessTokenProvider.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); var configuration = Configuration.Resolve(); if (string.Equals(configuration.CredentialMode, "integration", StringComparison.Ordinal)) return configuration.IntegrationCredential!; diff --git a/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917124346_AddIntegrationCredentialMcpResource.Designer.cs b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917124346_AddIntegrationCredentialMcpResource.Designer.cs new file mode 100644 index 00000000..0a4803e9 --- /dev/null +++ b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917124346_AddIntegrationCredentialMcpResource.Designer.cs @@ -0,0 +1,363 @@ +// +using System; +using Helpdesk.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Helpdesk.Infrastructure.SqliteMigrations.Migrations.Identity +{ + [DbContext(typeof(RatelDeskIdentityDbContext))] + [Migration("20260917124346_AddIntegrationCredentialMcpResource")] + partial class AddIntegrationCredentialMcpResource + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Helpdesk.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AuthorizationRevision") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0L); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("DisabledAtUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IsInstanceAdministrator") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Helpdesk.Infrastructure.Identity.IntegrationCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUnixMilliseconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsedAtUtc") + .HasColumnType("TEXT"); + + b.Property("McpResourceUri") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Permissions") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OwnerUserId", "CreatedAtUnixMilliseconds", "Id"); + + b.HasIndex("Purpose", "ExpiresAtUtc", "RevokedAtUtc"); + + b.ToTable("IntegrationCredentials", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917124346_AddIntegrationCredentialMcpResource.cs b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917124346_AddIntegrationCredentialMcpResource.cs new file mode 100644 index 00000000..3a11d5c8 --- /dev/null +++ b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/20260917124346_AddIntegrationCredentialMcpResource.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Helpdesk.Infrastructure.SqliteMigrations.Migrations.Identity +{ + /// + public partial class AddIntegrationCredentialMcpResource : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "McpResourceUri", + table: "IntegrationCredentials", + type: "TEXT", + maxLength: 2048, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "McpResourceUri", + table: "IntegrationCredentials"); + } + } +} diff --git a/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/RatelDeskIdentityDbContextModelSnapshot.cs b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/RatelDeskIdentityDbContextModelSnapshot.cs index cac1bf18..d724e259 100644 --- a/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/RatelDeskIdentityDbContextModelSnapshot.cs +++ b/src/Helpdesk.Infrastructure.SqliteMigrations/Migrations/Identity/RatelDeskIdentityDbContextModelSnapshot.cs @@ -124,6 +124,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastUsedAtUtc") .HasColumnType("TEXT"); + b.Property("McpResourceUri") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + b.Property("Name") .IsRequired() .HasMaxLength(128) diff --git a/src/Helpdesk.Infrastructure/Identity/IntegrationCredential.cs b/src/Helpdesk.Infrastructure/Identity/IntegrationCredential.cs index 3cd200f5..f26d4855 100644 --- a/src/Helpdesk.Infrastructure/Identity/IntegrationCredential.cs +++ b/src/Helpdesk.Infrastructure/Identity/IntegrationCredential.cs @@ -12,6 +12,12 @@ public sealed class IntegrationCredential public string Prefix { get; set; } = string.Empty; public string SecretHash { get; set; } = string.Empty; public string Purpose { get; set; } = string.Empty; + /// + /// Canonical HTTP MCP resource this credential is paired to. This is set + /// only for mcp-purpose credentials and prevents a bearer copied to + /// another gateway from being delegated there. + /// + public string? McpResourceUri { get; set; } public string? OrganizationId { get; set; } public string Permissions { get; set; } = string.Empty; public DateTimeOffset ExpiresAtUtc { get; set; } diff --git a/src/Helpdesk.Infrastructure/Identity/Migrations/20260917124509_AddIntegrationCredentialMcpResource.Designer.cs b/src/Helpdesk.Infrastructure/Identity/Migrations/20260917124509_AddIntegrationCredentialMcpResource.Designer.cs new file mode 100644 index 00000000..1905ecc9 --- /dev/null +++ b/src/Helpdesk.Infrastructure/Identity/Migrations/20260917124509_AddIntegrationCredentialMcpResource.Designer.cs @@ -0,0 +1,372 @@ +// +using System; +using Helpdesk.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Helpdesk.Infrastructure.Identity.Migrations +{ + [DbContext(typeof(RatelDeskIdentityDbContext))] + [Migration("20260917124509_AddIntegrationCredentialMcpResource")] + partial class AddIntegrationCredentialMcpResource + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Helpdesk.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("AuthorizationRevision") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisabledAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsInstanceAdministrator") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Helpdesk.Infrastructure.Identity.IntegrationCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUnixMilliseconds") + .HasColumnType("bigint"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("McpResourceUri") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("OrganizationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Permissions") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OwnerUserId", "CreatedAtUnixMilliseconds", "Id"); + + b.HasIndex("Purpose", "ExpiresAtUtc", "RevokedAtUtc"); + + b.ToTable("IntegrationCredentials", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Helpdesk.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Helpdesk.Infrastructure/Identity/Migrations/20260917124509_AddIntegrationCredentialMcpResource.cs b/src/Helpdesk.Infrastructure/Identity/Migrations/20260917124509_AddIntegrationCredentialMcpResource.cs new file mode 100644 index 00000000..4ac32212 --- /dev/null +++ b/src/Helpdesk.Infrastructure/Identity/Migrations/20260917124509_AddIntegrationCredentialMcpResource.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Helpdesk.Infrastructure.Identity.Migrations +{ + /// + public partial class AddIntegrationCredentialMcpResource : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "McpResourceUri", + table: "IntegrationCredentials", + type: "character varying(2048)", + maxLength: 2048, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "McpResourceUri", + table: "IntegrationCredentials"); + } + } +} diff --git a/src/Helpdesk.Infrastructure/Identity/Migrations/RatelDeskIdentityDbContextModelSnapshot.cs b/src/Helpdesk.Infrastructure/Identity/Migrations/RatelDeskIdentityDbContextModelSnapshot.cs index 8b815d2a..ce87f91a 100644 --- a/src/Helpdesk.Infrastructure/Identity/Migrations/RatelDeskIdentityDbContextModelSnapshot.cs +++ b/src/Helpdesk.Infrastructure/Identity/Migrations/RatelDeskIdentityDbContextModelSnapshot.cs @@ -129,6 +129,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastUsedAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("McpResourceUri") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + b.Property("Name") .IsRequired() .HasMaxLength(128) diff --git a/src/Helpdesk.Infrastructure/Identity/RatelDeskIdentityDbContext.cs b/src/Helpdesk.Infrastructure/Identity/RatelDeskIdentityDbContext.cs index 2c1f41ea..d076ef56 100644 --- a/src/Helpdesk.Infrastructure/Identity/RatelDeskIdentityDbContext.cs +++ b/src/Helpdesk.Infrastructure/Identity/RatelDeskIdentityDbContext.cs @@ -31,6 +31,7 @@ protected override void OnModelCreating(ModelBuilder builder) entity.Property(credential => credential.Prefix).HasMaxLength(32).IsRequired(); entity.Property(credential => credential.SecretHash).HasMaxLength(128).IsRequired(); entity.Property(credential => credential.Purpose).HasMaxLength(16).IsRequired(); + entity.Property(credential => credential.McpResourceUri).HasMaxLength(2048); entity.Property(credential => credential.OrganizationId).HasMaxLength(128); entity.Property(credential => credential.Permissions).HasMaxLength(4096).IsRequired(); entity.HasIndex(credential => credential.OwnerUserId); diff --git a/src/Helpdesk.Mcp.Http/Authorization/GatewayDelegationAuthenticationHandler.cs b/src/Helpdesk.Mcp.Http/Authorization/GatewayDelegationAuthenticationHandler.cs new file mode 100644 index 00000000..0ec39d60 --- /dev/null +++ b/src/Helpdesk.Mcp.Http/Authorization/GatewayDelegationAuthenticationHandler.cs @@ -0,0 +1,104 @@ +using System.Security.Claims; +using System.Text.Json; +using Helpdesk.AgentClient; +using Helpdesk.Mcp.Http.Configuration; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; + +namespace Helpdesk.Mcp.Http.Authorization; + +/// +/// Authenticates a locally paired HTTP MCP caller by exchanging its MCP-only +/// opaque credential at the configured API. The inbound credential is used +/// only for that narrow exchange and is never retained or sent to business +/// endpoints. +/// +public sealed class GatewayDelegationAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + System.Text.Encodings.Web.UrlEncoder encoder, + IHttpClientFactory clients, + IOptions mcpOptions) + : AuthenticationHandler(options, logger, encoder) +{ + public const string SchemeName = "HelpdeskMcpGateway"; + public const string ExecutionTokenItemKey = "RatelDesk.Mcp.ExecutionToken"; + public const string DelegationClientName = "Helpdesk.Mcp.Http.Delegation"; + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + protected override async Task HandleAuthenticateAsync() + { + var authorization = Request.Headers.Authorization.ToString(); + if (!authorization.StartsWith("Bearer rdk_", StringComparison.OrdinalIgnoreCase)) + return AuthenticateResult.NoResult(); + + try + { + using var delegationRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/mcp/execution-token"); + delegationRequest.Headers.TryAddWithoutValidation("Authorization", authorization); + delegationRequest.Headers.TryAddWithoutValidation("X-RatelDesk-Mcp-Resource", mcpOptions.Value.PublicResourceUri.TrimEnd('/')); + using var response = await clients.CreateClient(DelegationClientName) + .SendAsync(delegationRequest, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted) + .ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + return AuthenticateResult.Fail("The MCP credential could not be delegated for this gateway."); + + await using var stream = await response.Content.ReadAsStreamAsync(Context.RequestAborted).ConfigureAwait(false); + var delegated = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, Context.RequestAborted).ConfigureAwait(false); + if (delegated is null || string.IsNullOrWhiteSpace(delegated.AccessToken) || delegated.ExpiresAtUtc <= DateTimeOffset.UtcNow || + string.IsNullOrWhiteSpace(delegated.UserId)) + { + return AuthenticateResult.Fail("The MCP gateway received an invalid execution credential."); + } + + Context.Items[ExecutionTokenItemKey] = delegated.AccessToken; + var claims = new List + { + new(ClaimTypes.NameIdentifier, delegated.UserId), + new(ClaimTypes.Name, delegated.Name ?? delegated.UserId), + new("auth_mode", "mcp"), + new("token_use", "mcp_gateway") + }; + if (!string.IsNullOrWhiteSpace(delegated.PrimaryOrganizationId)) + claims.Add(new Claim("organization_id", delegated.PrimaryOrganizationId)); + foreach (var permission in delegated.Permissions ?? []) + claims.Add(new Claim("delegated_permission", permission)); + foreach (var organizationId in delegated.AllowedOrganizationIds ?? []) + claims.Add(new Claim("allowed_organization_id", organizationId)); + + var identity = new ClaimsIdentity(claims, SchemeName, ClaimTypes.Name, ClaimTypes.Role); + return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName)); + } + catch (OperationCanceledException) when (Context.RequestAborted.IsCancellationRequested) + { + throw; + } + catch (Exception exception) when (exception is HttpRequestException or JsonException) + { + return AuthenticateResult.Fail("The MCP gateway could not reach the delegation endpoint."); + } + } + + private sealed record DelegatedCredential( + string AccessToken, + DateTimeOffset ExpiresAtUtc, + string UserId, + string? Name, + string? PrimaryOrganizationId, + string[]? Permissions, + string[]? AllowedOrganizationIds); +} + +/// Gets only the API-issued execution token for the active request. +public sealed class GatewayExecutionTokenProvider(IHttpContextAccessor httpContextAccessor) : IAgentAccessTokenProvider +{ + public Task GetAccessTokenAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var token = httpContextAccessor.HttpContext?.Items[GatewayDelegationAuthenticationHandler.ExecutionTokenItemKey] as string; + return string.IsNullOrWhiteSpace(token) + ? Task.FromException(new AgentClientValidationException("No delegated MCP execution credential is available for this request.")) + : Task.FromResult(token); + } +} diff --git a/src/Helpdesk.Mcp.Http/Configuration/AuthentikMcpOptions.cs b/src/Helpdesk.Mcp.Http/Configuration/AuthentikMcpOptions.cs index f586c743..28e8894b 100644 --- a/src/Helpdesk.Mcp.Http/Configuration/AuthentikMcpOptions.cs +++ b/src/Helpdesk.Mcp.Http/Configuration/AuthentikMcpOptions.cs @@ -21,6 +21,8 @@ public sealed class HelpdeskMcpHttpOptions public string ExpectedApiBaseUrl { get; init; } = string.Empty; public string PublicResourceUri { get; init; } = string.Empty; public string[] AllowedOrigins { get; init; } = []; + /// Ingress mode: gateway for paired local credentials or authentik for external OAuth. + public string AuthenticationMode { get; init; } = "authentik"; } public sealed class AuthentikMcpOptionsValidator(IOptions mcpOptions) @@ -58,5 +60,6 @@ public static bool IsAllowedOrigin(string value) => Uri.TryCreate(value, UriKind && (uri.AbsolutePath is "" or "/") && string.IsNullOrEmpty(uri.Query) && string.IsNullOrEmpty(uri.Fragment); + public static bool IsAuthenticationMode(string value) => value is "gateway" or "authentik"; internal static string Normalize(string value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri.AbsoluteUri.TrimEnd('/') : value; } diff --git a/src/Helpdesk.Mcp.Http/Program.cs b/src/Helpdesk.Mcp.Http/Program.cs index 303380f3..17e38bf8 100644 --- a/src/Helpdesk.Mcp.Http/Program.cs +++ b/src/Helpdesk.Mcp.Http/Program.cs @@ -7,10 +7,12 @@ using Helpdesk.Mcp.Prompts; using Helpdesk.Mcp.Resources; using Helpdesk.Mcp.Tools; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; +using Microsoft.Extensions.DependencyInjection.Extensions; using ModelContextProtocol.Authentication; using Microsoft.IdentityModel.Tokens; using System.Threading.RateLimiting; @@ -23,7 +25,7 @@ public static void Main(string[] args) { const string McpScheme = "HelpdeskMcp"; const string JwtScheme = "HelpdeskMcpJwt"; - const string PolicyName = "HelpdeskMcpRemote"; + const string PolicyName = "HelpdeskMcpAccess"; var builder = WebApplication.CreateBuilder(args); builder.ConfigureOpenTelemetry(); @@ -31,6 +33,8 @@ public static void Main(string[] args) builder.Services.AddServiceDiscovery(); var httpOptions = builder.Configuration.GetSection(HelpdeskMcpHttpOptions.SectionName).Get() ?? new HelpdeskMcpHttpOptions(); + var authenticationMode = httpOptions.AuthenticationMode.Trim().ToLowerInvariant(); + var usesGatewayCredentials = string.Equals(authenticationMode, "gateway", StringComparison.Ordinal); var configurationPath = string.IsNullOrWhiteSpace(httpOptions.ConfigurationPath) ? Environment.GetEnvironmentVariable(HelpdeskMcpTarget.ConfigurationEnvironmentVariable) : httpOptions.ConfigurationPath; @@ -41,23 +45,34 @@ public static void Main(string[] args) .Bind(builder.Configuration.GetSection(HelpdeskMcpHttpOptions.SectionName)) .Validate(options => AuthentikMcpOptionsValidator.IsCanonicalMcpResource(options.PublicResourceUri), "Helpdesk:Mcp:PublicResourceUri must be an absolute HTTPS /mcp URI.") .Validate(options => options.AllowedOrigins.Length > 0 && options.AllowedOrigins.All(AuthentikMcpOptionsValidator.IsAllowedOrigin), "Helpdesk:Mcp:AllowedOrigins must contain one or more absolute HTTP(S) origins without paths.") + .Validate(options => AuthentikMcpOptionsValidator.IsAuthenticationMode(options.AuthenticationMode.Trim().ToLowerInvariant()), "Helpdesk:Mcp:AuthenticationMode must be gateway or authentik.") .ValidateOnStart(); - builder.Services.AddSingleton, AuthentikMcpOptionsValidator>(); - builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(AuthentikMcpOptions.SectionName)) - .ValidateOnStart(); + if (usesGatewayCredentials && !string.Equals(agentConfiguration.CredentialMode, "gateway", StringComparison.OrdinalIgnoreCase)) + throw new AgentClientValidationException("Local HTTP MCP gateway mode requires credentialMode=gateway and does not accept a deployment credential."); - var authOptions = builder.Configuration.GetSection(AuthentikMcpOptions.SectionName).Get() ?? new AuthentikMcpOptions(); - var authorizationServer = $"{authOptions.Authority.TrimEnd('/')}/"; - var hostContext = target.ToHostContext("http", httpOptions.PublicResourceUri); - var resourceMetadataUri = new Uri(new Uri(hostContext.ResourceUri), "/.well-known/oauth-protected-resource/mcp"); - var protectedResourceMetadata = new ProtectedResourceMetadata + AuthentikMcpOptions? authOptions = null; + if (!usesGatewayCredentials) { - Resource = hostContext.ResourceUri, - AuthorizationServers = [authorizationServer], - ScopesSupported = authOptions.RequiredScopes, - BearerMethodsSupported = ["header"] - }; + builder.Services.AddSingleton, AuthentikMcpOptionsValidator>(); + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(AuthentikMcpOptions.SectionName)) + .ValidateOnStart(); + authOptions = builder.Configuration.GetSection(AuthentikMcpOptions.SectionName).Get() ?? new AuthentikMcpOptions(); + } + + var hostContext = target.ToHostContext("http", httpOptions.PublicResourceUri); + var resourceMetadataUri = usesGatewayCredentials + ? null + : new Uri(new Uri(hostContext.ResourceUri), "/.well-known/oauth-protected-resource/mcp"); + var protectedResourceMetadata = usesGatewayCredentials + ? null + : new ProtectedResourceMetadata + { + Resource = hostContext.ResourceUri, + AuthorizationServers = [$"{authOptions!.Authority.TrimEnd('/')}/"], + ScopesSupported = authOptions.RequiredScopes, + BearerMethodsSupported = ["header"] + }; IServiceProvider? applicationServices = null; var resourceTarget = new HelpdeskResources( () => applicationServices?.GetRequiredService() @@ -65,14 +80,35 @@ public static void Main(string[] args) hostContext); builder.Services.AddHelpdeskMcpCore(agentConfiguration, new ReadOnlyHelpdeskMcpConfigurationSurface(agentConfiguration), hostContext); - builder.Services.AddAuthentication(options => + var authentication = builder.Services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = usesGatewayCredentials ? GatewayDelegationAuthenticationHandler.SchemeName : McpScheme; + options.DefaultChallengeScheme = usesGatewayCredentials ? GatewayDelegationAuthenticationHandler.SchemeName : McpScheme; + }); + if (usesGatewayCredentials) + { + builder.Services.AddHttpContextAccessor(); + builder.Services.AddHttpClient(GatewayDelegationAuthenticationHandler.DelegationClientName, client => + { + client.BaseAddress = new Uri(hostContext.CanonicalApiBaseUrl); + client.Timeout = TimeSpan.FromSeconds(20); + }); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(provider => new HelpdeskAgentClient( + agentConfiguration, + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService())); + authentication.AddScheme( + GatewayDelegationAuthenticationHandler.SchemeName, + _ => { }); + } + else { - options.DefaultAuthenticateScheme = McpScheme; - options.DefaultChallengeScheme = McpScheme; - }) - .AddJwtBearer(JwtScheme, options => + authentication.AddJwtBearer(JwtScheme, options => { - options.Authority = authOptions.Authority; + options.Authority = authOptions!.Authority; options.Audience = authOptions.Audience; options.RequireHttpsMetadata = true; options.TokenValidationParameters = new TokenValidationParameters @@ -88,17 +124,21 @@ public static void Main(string[] args) .AddMcp(McpScheme, "Helpdesk MCP", options => { options.ForwardAuthenticate = JwtScheme; - options.ResourceMetadataUri = resourceMetadataUri; - options.ResourceMetadata = protectedResourceMetadata; + options.ResourceMetadataUri = resourceMetadataUri!; + options.ResourceMetadata = protectedResourceMetadata!; }); + } builder.Services.AddSingleton(); builder.Services.AddAuthorization(options => options.AddPolicy(PolicyName, policy => { - policy.AddAuthenticationSchemes(McpScheme); + policy.AddAuthenticationSchemes(usesGatewayCredentials ? GatewayDelegationAuthenticationHandler.SchemeName : McpScheme); policy.RequireAuthenticatedUser(); - policy.Requirements.Add(new RequiredMcpClaimsRequirement( - authOptions.RequiredScopes.ToHashSet(StringComparer.Ordinal), - authOptions.RequiredGroups.ToHashSet(StringComparer.Ordinal))); + if (!usesGatewayCredentials) + { + policy.Requirements.Add(new RequiredMcpClaimsRequirement( + authOptions!.RequiredScopes.ToHashSet(StringComparer.Ordinal), + authOptions.RequiredGroups.ToHashSet(StringComparer.Ordinal))); + } })); builder.Services.AddRateLimiter(options => options.AddConcurrencyLimiter("HelpdeskMcp", limiter => { @@ -137,7 +177,8 @@ public static void Main(string[] args) app.UseAuthorization(); app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions { Predicate = check => check.Tags.Contains("live") }); app.MapHealthChecks("/health/ready"); - app.MapGet("/.well-known/oauth-protected-resource/mcp", () => Results.Json(protectedResourceMetadata)).AllowAnonymous(); + if (protectedResourceMetadata is not null) + app.MapGet("/.well-known/oauth-protected-resource/mcp", () => Results.Json(protectedResourceMetadata)).AllowAnonymous(); app.MapMcp("/mcp").RequireAuthorization(PolicyName).RequireRateLimiting("HelpdeskMcp"); app.Run(); } diff --git a/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs b/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs index 0b2c60c8..7b7d1c01 100644 --- a/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs +++ b/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs @@ -88,6 +88,34 @@ public async Task Forward_sqlite_migration_backfills_the_sort_key_for_existing_c Assert.Equal(createdAtUtc.ToUnixTimeMilliseconds(), Convert.ToInt64(await verify.ExecuteScalarAsync())); } + [Fact] + public async Task Create_endpoint_pairs_an_mcp_credential_to_its_canonical_resource() + { + await using var harness = await Harness.CreateAsync(); + var response = await harness.Client.PostAsJsonAsync("/api/v1/integration-credentials/", new + { + name = "MCP client", + purpose = "mcp", + organizationId = "org-a", + permissions = new[] { "Incident.Read" }, + lifetimeDays = 30, + mcpResourceUri = "https://helpdesk.example/mcp/" + }); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var created = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + Assert.Equal("https://helpdesk.example/mcp", created.McpResourceUri); + Assert.StartsWith("rdk_", created.Secret, StringComparison.Ordinal); + Assert.Equal("no-store", response.Headers.CacheControl?.ToString()); + + await using var scope = harness.Services.CreateAsyncScope(); + var credential = await scope.ServiceProvider.GetRequiredService() + .IntegrationCredentials.SingleAsync(); + Assert.Equal("mcp", credential.Purpose); + Assert.Equal("https://helpdesk.example/mcp", credential.McpResourceUri); + } + private static IntegrationCredential Credential(string ownerId, string name, DateTimeOffset createdAtUtc) => new() { Id = Guid.NewGuid(), @@ -116,7 +144,7 @@ public static async Task CreateAsync() var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Development" }); builder.WebHost.UseTestServer(); builder.Services.AddDbContext(options => options.UseSqlite(connection)); - builder.Services.AddSingleton(Substitute.For()); + builder.Services.AddSingleton(new TestAccessService()); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddAuthentication("Test").AddScheme("Test", _ => { }); @@ -168,4 +196,17 @@ private sealed class StaticOwnerResolver : IIntegrationCredentialOwnerResolver public Task ResolveAsync(ClaimsPrincipal principal, CancellationToken cancellationToken = default) => Task.FromResult(new IntegrationCredentialOwner("owner")); } + + private sealed class TestAccessService : ICurrentUserAccessService + { + private static readonly CurrentUserAccessProfile Profile = new( + true, "owner", "owner@example.test", "org-a", null, null, false, + new HashSet(StringComparer.OrdinalIgnoreCase), + new HashSet(["Incident.Read"], StringComparer.OrdinalIgnoreCase), + new HashSet(["org-a"], StringComparer.OrdinalIgnoreCase), + new HashSet(StringComparer.OrdinalIgnoreCase)); + + public Task ResolveAsync(ClaimsPrincipal user, CancellationToken ct = default) + => Task.FromResult(Profile); + } } diff --git a/tests/Helpdesk.Tests/Api/McpGatewayDelegationEndpointTests.cs b/tests/Helpdesk.Tests/Api/McpGatewayDelegationEndpointTests.cs new file mode 100644 index 00000000..93010d74 --- /dev/null +++ b/tests/Helpdesk.Tests/Api/McpGatewayDelegationEndpointTests.cs @@ -0,0 +1,168 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using Helpdesk.API.Authentication; +using Helpdesk.API.Endpoints.Authentication; +using Helpdesk.Infrastructure.Identity; +using Helpdesk.Shared.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Helpdesk.Tests.Api; + +public sealed class McpGatewayDelegationEndpointTests +{ + [Fact] + public async Task Paired_mcp_credential_delegates_only_to_its_resource_and_revocation_invalidates_execution() + { + await using var harness = await Harness.CreateAsync(); + var credential = await harness.CreateCredentialAsync("https://helpdesk.example/mcp"); + using var delegation = new HttpRequestMessage(HttpMethod.Post, "/api/v1/mcp/execution-token"); + delegation.Headers.Authorization = new AuthenticationHeaderValue("Bearer", credential.Bearer); + delegation.Headers.Add(McpGatewayDelegationEndpoints.ResourceHeader, "https://helpdesk.example/mcp"); + + using var delegatedResponse = await harness.Client.SendAsync(delegation); + Assert.True(delegatedResponse.IsSuccessStatusCode, $"Delegation status {delegatedResponse.StatusCode}; challenge {string.Join("; ", delegatedResponse.Headers.WwwAuthenticate)}."); + var delegated = await delegatedResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(delegated); + Assert.StartsWith(McpExecutionTokenService.TokenPrefix, delegated.AccessToken, StringComparison.Ordinal); + + using var directMcpRequest = new HttpRequestMessage(HttpMethod.Get, "/business"); + directMcpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", credential.Bearer); + using var directMcpResponse = await harness.Client.SendAsync(directMcpRequest); + Assert.Equal(HttpStatusCode.Unauthorized, directMcpResponse.StatusCode); + + using var businessRequest = new HttpRequestMessage(HttpMethod.Get, "/business"); + businessRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", delegated.AccessToken); + using var businessResponse = await harness.Client.SendAsync(businessRequest); + Assert.Equal(HttpStatusCode.OK, businessResponse.StatusCode); + + await harness.RevokeAsync(credential.Id); + using var revokedRequest = new HttpRequestMessage(HttpMethod.Get, "/business"); + revokedRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", delegated.AccessToken); + using var revokedResponse = await harness.Client.SendAsync(revokedRequest); + Assert.Equal(HttpStatusCode.Unauthorized, revokedResponse.StatusCode); + } + + [Fact] + public async Task Paired_mcp_credential_cannot_delegate_to_another_gateway_resource() + { + await using var harness = await Harness.CreateAsync(); + var credential = await harness.CreateCredentialAsync("https://helpdesk.example/mcp"); + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/mcp/execution-token"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", credential.Bearer); + request.Headers.Add(McpGatewayDelegationEndpoints.ResourceHeader, "https://other.example/mcp"); + + using var response = await harness.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + private sealed class Harness(WebApplication application) : IAsyncDisposable + { + public HttpClient Client { get; } = application.GetTestClient(); + + public static async Task CreateAsync() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Development" }); + builder.WebHost.UseTestServer(); + builder.Services.AddDataProtection(); + var identityConnection = new SqliteConnection("Data Source=:memory:"); + await identityConnection.OpenAsync(); + builder.Services.AddSingleton(identityConnection); + builder.Services.AddDbContext(options => options.UseSqlite(identityConnection)); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(new TestAccessService()); + builder.Services.AddAuthentication(IntegrationCredentialAuthenticationHandler.McpSchemeName) + .AddScheme(IntegrationCredentialAuthenticationHandler.McpSchemeName, options => options.Purpose = IntegrationCredentialAuthenticationHandler.McpPurpose) + .AddScheme(McpExecutionAuthenticationHandler.SchemeName, _ => { }); + builder.Services.AddAuthorization(options => + { + options.AddPolicy(McpGatewayDelegationEndpoints.DelegationPolicy, policy => + { + policy.AddAuthenticationSchemes(IntegrationCredentialAuthenticationHandler.McpSchemeName); + policy.RequireAuthenticatedUser(); + }); + options.AddPolicy("ExecutionOnly", policy => + { + policy.AddAuthenticationSchemes(McpExecutionAuthenticationHandler.SchemeName); + policy.RequireAuthenticatedUser(); + }); + }); + var application = builder.Build(); + application.UseAuthentication(); + application.UseAuthorization(); + application.MapMcpGatewayDelegationEndpoints(); + application.MapGet("/business", () => Results.Ok()).RequireAuthorization("ExecutionOnly"); + await application.StartAsync(); + await using (var scope = application.Services.CreateAsyncScope()) + { + var identity = scope.ServiceProvider.GetRequiredService(); + await identity.Database.EnsureCreatedAsync(); + identity.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner", IsEnabled = true }); + await identity.SaveChangesAsync(); + } + + return new Harness(application); + } + + public async Task<(Guid Id, string Bearer)> CreateCredentialAsync(string resourceUri) + { + var id = Guid.NewGuid(); + const string secret = "test-secret"; + await using var scope = application.Services.CreateAsyncScope(); + var identity = scope.ServiceProvider.GetRequiredService(); + identity.IntegrationCredentials.Add(new IntegrationCredential + { + Id = id, + OwnerUserId = "owner", + Name = "MCP test", + Prefix = "rdk_test", + SecretHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(secret))), + Purpose = IntegrationCredentialAuthenticationHandler.McpPurpose, + McpResourceUri = resourceUri, + OrganizationId = "org-a", + Permissions = "Incident.Read", + CreatedAtUtc = DateTimeOffset.UtcNow, + CreatedAtUnixMilliseconds = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(1) + }); + await identity.SaveChangesAsync(); + return (id, $"rdk_{id:N}_{secret}"); + } + + public async Task RevokeAsync(Guid id) + { + await using var scope = application.Services.CreateAsyncScope(); + var identity = scope.ServiceProvider.GetRequiredService(); + var credential = await identity.IntegrationCredentials.SingleAsync(candidate => candidate.Id == id); + credential.RevokedAtUtc = DateTimeOffset.UtcNow; + await identity.SaveChangesAsync(); + } + + public async ValueTask DisposeAsync() => await application.DisposeAsync(); + } + + private sealed class TestAccessService : ICurrentUserAccessService + { + private static readonly CurrentUserAccessProfile Profile = new( + true, "owner", null, "org-a", null, null, false, + new HashSet(StringComparer.OrdinalIgnoreCase), + new HashSet(["Incident.Read"], StringComparer.OrdinalIgnoreCase), + new HashSet(["org-a"], StringComparer.OrdinalIgnoreCase), + new HashSet(StringComparer.OrdinalIgnoreCase)); + + public Task ResolveAsync(System.Security.Claims.ClaimsPrincipal user, CancellationToken ct = default) + => Task.FromResult(Profile); + } +} diff --git a/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs b/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs index b3156140..21c6ed55 100644 --- a/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs +++ b/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs @@ -54,6 +54,7 @@ public async Task OpenApi_V1_Json_IsServed() var schemes = document.RootElement.GetProperty("components").GetProperty("securitySchemes"); Assert.Equal("JWT", schemes.GetProperty("JwtBearer").GetProperty("bearerFormat").GetString()); Assert.Equal("opaque rdk credential", schemes.GetProperty("IntegrationCredential").GetProperty("bearerFormat").GetString()); + Assert.Equal("paired opaque rdk credential", schemes.GetProperty("McpIntegrationCredential").GetProperty("bearerFormat").GetString()); Assert.Equal("cookie", schemes.GetProperty("LocalSession").GetProperty("in").GetString()); var integrationCredentialsEndpoint = _factory.Services.GetServices() @@ -69,6 +70,7 @@ public async Task OpenApi_V1_Json_IsServed() Assert.Equal(["OrchestrationM2M"], SecuritySchemes(document, "/api/v1/orchestration/provider/m2m/ping", "get")); Assert.Equal(["AiAgentJwt"], SecuritySchemes(document, "/api/v1/auth/ai-agent/status", "get")); Assert.Equal(["IntegrationCredential", "JwtBearer", "LocalSession"], SecuritySchemes(document, "/api/v1/incidents", "get")); + Assert.Equal(["McpIntegrationCredential"], SecuritySchemes(document, "/api/v1/mcp/execution-token", "post")); } private static string[] SecuritySchemes(JsonDocument document, string path, string method) diff --git a/tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs b/tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs index 29c59fe9..22fb7869 100644 --- a/tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs +++ b/tests/Helpdesk.Tests/Mcp/McpHttpHostTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using Helpdesk.AgentClient; using Helpdesk.Mcp.Http.Observability; +using Helpdesk.Mcp.Http.Authorization; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; @@ -138,6 +139,46 @@ public async Task Inbound_mcp_bearer_is_never_forwarded_to_the_helpdesk_api() Assert.Contains("CorrelationId=corr-123", auditMessage, StringComparison.Ordinal); } + [Fact] + public async Task Paired_gateway_callers_receive_request_scoped_execution_credentials() + { + using var environment = new McpHostEnvironment(gateway: true); + var delegation = new GatewayDelegationProbe(); + var outbound = new AgentClientBoundaryProbe(); + using var factory = CreateFactory(environment.SigningKey, boundaryProbe: outbound, delegationProbe: delegation); + using var client = CreateClient(factory); + + var calls = await Task.WhenAll( + HttpCallAsync(client, "rdk_local-a", 1, "tools/call", ToolCallParameters("helpdesk_auth", "status")), + HttpCallAsync(client, "rdk_local-b", 2, "tools/call", ToolCallParameters("helpdesk_auth", "status"))); + using var first = calls[0]; + using var second = calls[1]; + + Assert.All(calls, response => Assert.Equal("completed", StructuredContent(response.RootElement).GetProperty("status").GetString())); + Assert.Equal(2, delegation.Requests); + Assert.DoesNotContain(outbound.ApiAuthorizations, value => value.Contains("rdk_local-a", StringComparison.Ordinal)); + Assert.DoesNotContain(outbound.ApiAuthorizations, value => value.Contains("rdk_local-b", StringComparison.Ordinal)); + Assert.Equal(["Bearer rdx_local-a", "Bearer rdx_local-b"], outbound.ApiAuthorizations.Order(StringComparer.Ordinal)); + } + + [Fact] + public async Task Paired_gateway_mode_does_not_advertise_oauth_metadata() + { + using var environment = new McpHostEnvironment(gateway: true); + var delegation = new GatewayDelegationProbe(); + using var factory = CreateFactory(environment.SigningKey, delegationProbe: delegation); + using var client = CreateClient(factory); + + using var metadata = await client.GetAsync("/.well-known/oauth-protected-resource/mcp"); + using var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") { Content = McpRequest("initialize") }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "rdk_local-a"); + request.Headers.Accept.ParseAdd("application/json, text/event-stream"); + using var initialize = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, metadata.StatusCode); + Assert.Equal(HttpStatusCode.OK, initialize.StatusCode); + } + [Fact] public async Task Http_health_resource_uses_the_registered_outbound_agent_client() { @@ -337,7 +378,7 @@ public async Task Stdio_and_authenticated_http_discovery_are_protocol_equivalent Assert.Equal(0, outbound.CallCount); } - private static WebApplicationFactory CreateFactory(ECDsa signingKey, AuditLogCollector? audit = null, OutboundCallProbe? outboundProbe = null, McpJwtValidation? jwt = null, AgentClientBoundaryProbe? boundaryProbe = null) + private static WebApplicationFactory CreateFactory(ECDsa signingKey, AuditLogCollector? audit = null, OutboundCallProbe? outboundProbe = null, McpJwtValidation? jwt = null, AgentClientBoundaryProbe? boundaryProbe = null, GatewayDelegationProbe? delegationProbe = null) => new WebApplicationFactory().WithWebHostBuilder(builder => { builder.UseEnvironment("Development"); @@ -359,6 +400,11 @@ public async Task Stdio_and_authenticated_http_discovery_are_protocol_equivalent services.AddHttpClient(Helpdesk.AgentClient.HelpdeskAgentClient.AuthHttpClientName) .ConfigurePrimaryHttpMessageHandler(outboundProbe.CreateHandler); } + if (delegationProbe is not null) + { + services.AddHttpClient(GatewayDelegationAuthenticationHandler.DelegationClientName) + .ConfigurePrimaryHttpMessageHandler(delegationProbe.CreateHandler); + } services.PostConfigure("HelpdeskMcpJwt", options => { @@ -518,6 +564,7 @@ private sealed class McpHostEnvironment : IDisposable private const string ProdApiVariable = "RATELDESK_MCP_PROD_API_BASE_URL"; private const string PublicResourceVariable = "Helpdesk__Mcp__PublicResourceUri"; private const string AllowedOriginVariable = "Helpdesk__Mcp__AllowedOrigins__0"; + private const string AuthenticationModeVariable = "Helpdesk__Mcp__AuthenticationMode"; private const string AuthorityVariable = "Authentication__AuthentikMcp__Authority"; private const string AudienceVariable = "Authentication__AuthentikMcp__Audience"; private const string ScopeVariable = "Authentication__AuthentikMcp__RequiredScopes__0"; @@ -528,27 +575,30 @@ private sealed class McpHostEnvironment : IDisposable private readonly string? _previousProdApi = Environment.GetEnvironmentVariable(ProdApiVariable); private readonly string? _previousPublicResource = Environment.GetEnvironmentVariable(PublicResourceVariable); private readonly string? _previousAllowedOrigin = Environment.GetEnvironmentVariable(AllowedOriginVariable); + private readonly string? _previousAuthenticationMode = Environment.GetEnvironmentVariable(AuthenticationModeVariable); private readonly string? _previousAuthority = Environment.GetEnvironmentVariable(AuthorityVariable); private readonly string? _previousAudience = Environment.GetEnvironmentVariable(AudienceVariable); private readonly string? _previousScope = Environment.GetEnvironmentVariable(ScopeVariable); private readonly string? _previousGroup = Environment.GetEnvironmentVariable(GroupVariable); private readonly string _configurationPath = Path.Combine(Path.GetTempPath(), $"helpdesk-mcp-http-{Guid.NewGuid():N}.json"); - public McpHostEnvironment(string instance = "dev", string apiBaseUrl = "https://api.example", string publicResourceUri = ResourceUri) + public McpHostEnvironment(string instance = "dev", string apiBaseUrl = "https://api.example", string publicResourceUri = ResourceUri, bool gateway = false) { File.WriteAllText(_configurationPath, JsonSerializer.Serialize(new { apiBaseUrl, - authentikTokenUrl = "https://auth.example/token", - authentikClientId = "helpdesk-mcp-test", - authentikUsername = "helpdesk-mcp-test", - authentikAppPassword = "test-only-password" + credentialMode = gateway ? "gateway" : "authentik", + authentikTokenUrl = gateway ? null : "https://auth.example/token", + authentikClientId = gateway ? null : "helpdesk-mcp-test", + authentikUsername = gateway ? null : "helpdesk-mcp-test", + authentikAppPassword = gateway ? null : "test-only-password" })); Environment.SetEnvironmentVariable(ConfigurationPathVariable, _configurationPath); Environment.SetEnvironmentVariable(InstanceVariable, instance); Environment.SetEnvironmentVariable(instance == "dev" ? DevApiVariable : ProdApiVariable, apiBaseUrl); Environment.SetEnvironmentVariable(PublicResourceVariable, publicResourceUri); Environment.SetEnvironmentVariable(AllowedOriginVariable, "https://client.example"); + Environment.SetEnvironmentVariable(AuthenticationModeVariable, gateway ? "gateway" : "authentik"); Environment.SetEnvironmentVariable(AuthorityVariable, Authority); Environment.SetEnvironmentVariable(AudienceVariable, publicResourceUri); Environment.SetEnvironmentVariable(ScopeVariable, "helpdesk.mcp"); @@ -569,6 +619,7 @@ public void Dispose() Environment.SetEnvironmentVariable(ProdApiVariable, _previousProdApi); Environment.SetEnvironmentVariable(PublicResourceVariable, _previousPublicResource); Environment.SetEnvironmentVariable(AllowedOriginVariable, _previousAllowedOrigin); + Environment.SetEnvironmentVariable(AuthenticationModeVariable, _previousAuthenticationMode); Environment.SetEnvironmentVariable(AuthorityVariable, _previousAuthority); Environment.SetEnvironmentVariable(AudienceVariable, _previousAudience); Environment.SetEnvironmentVariable(ScopeVariable, _previousScope); @@ -625,10 +676,12 @@ private sealed class AgentClientBoundaryProbe private int _tokenRequests; private int _apiRequests; private string? _apiAuthorization; + private readonly ConcurrentQueue _apiAuthorizations = new(); public int TokenRequests => Volatile.Read(ref _tokenRequests); public int ApiRequests => Volatile.Read(ref _apiRequests); public string? ApiAuthorization => Volatile.Read(ref _apiAuthorization); + public IReadOnlyCollection ApiAuthorizations => _apiAuthorizations.ToArray(); public HttpMessageHandler CreateHandler() => new ProbeHandler(this); @@ -647,6 +700,8 @@ protected override Task SendAsync(HttpRequestMessage reques Interlocked.Increment(ref probe._apiRequests); Volatile.Write(ref probe._apiAuthorization, request.Headers.Authorization?.ToString()); + if (request.Headers.Authorization is not null) + probe._apiAuthorizations.Enqueue(request.Headers.Authorization.ToString()); return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"correlationId\":\"corr-123\"}", Encoding.UTF8, "application/json") @@ -655,6 +710,45 @@ protected override Task SendAsync(HttpRequestMessage reques } } + private sealed class GatewayDelegationProbe + { + private int _requests; + + public int Requests => Volatile.Read(ref _requests); + + public HttpMessageHandler CreateHandler() => new ProbeHandler(this); + + private sealed class ProbeHandler(GatewayDelegationProbe probe) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Interlocked.Increment(ref probe._requests); + var inbound = request.Headers.TryGetValues("Authorization", out var authorizationValues) + ? authorizationValues.SingleOrDefault()?.Replace("Bearer ", string.Empty, StringComparison.OrdinalIgnoreCase) + : null; + var resource = request.Headers.GetValues("X-RatelDesk-Mcp-Resource").SingleOrDefault(); + if (string.IsNullOrWhiteSpace(inbound) || string.IsNullOrWhiteSpace(resource)) + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized)); + + var suffix = inbound["rdk_".Length..]; + var json = JsonSerializer.Serialize(new + { + accessToken = $"rdx_{suffix}", + expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(1), + userId = $"user-{suffix}", + name = $"User {suffix}", + primaryOrganizationId = $"org-{suffix}", + permissions = new[] { "Incident.Read" }, + allowedOrganizationIds = new[] { $"org-{suffix}" } + }); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + } + } + } + private sealed record McpJwtValidation(string ResourceUri, string Authority = McpHttpHostTests.Authority); private sealed class StdioMcpClient(Process process, string configurationPath) : IAsyncDisposable From b1ffe102deb186a4649a65fe98ad573311ecb55f Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 15:40:47 +0200 Subject: [PATCH 14/32] fix(mcp): bind self assignment to caller identity --- .../Authentication/AuthenticationEndpoints.cs | 5 +- .../HelpdeskAgentClient.cs | 6 +- src/Helpdesk.Mcp.Core/Tools/HelpdeskTools.cs | 32 ++++++--- .../DTOs/Auth/CurrentUserAccessDto.cs | 3 + .../Api/LocalAuthenticationEndpointsTests.cs | 1 + .../Mcp/HelpdeskToolsMutationTests.cs | 66 +++++++++++++++++++ 6 files changed, 102 insertions(+), 11 deletions(-) diff --git a/src/Helpdesk.API/Endpoints/Authentication/AuthenticationEndpoints.cs b/src/Helpdesk.API/Endpoints/Authentication/AuthenticationEndpoints.cs index f4c7ac37..7a6a284e 100644 --- a/src/Helpdesk.API/Endpoints/Authentication/AuthenticationEndpoints.cs +++ b/src/Helpdesk.API/Endpoints/Authentication/AuthenticationEndpoints.cs @@ -21,7 +21,7 @@ public static void MapCurrentUserAccessEndpoint(this IEndpointRouteBuilder app) CancellationToken ct) => { var access = await accessService.ResolveAsync(user, ct); - return Results.Ok(ToDto(access)); + return Results.Ok(ToDto(access, user.FindFirstValue(ClaimTypes.NameIdentifier))); }) .RequireAuthorization() .WithTags("Authentication") @@ -83,7 +83,7 @@ static string CreateJwt(User user, IConfiguration config) } } - private static CurrentUserAccessDto ToDto(CurrentUserAccessProfile access) => new( + private static CurrentUserAccessDto ToDto(CurrentUserAccessProfile access, string? userId) => new( access.IsAuthenticated, access.Name, access.Email, @@ -96,6 +96,7 @@ static string CreateJwt(User user, IConfiguration config) access.AllowedOrganizationIds.Order(StringComparer.OrdinalIgnoreCase).ToArray(), access.ManagedOrganizationIds.Order(StringComparer.OrdinalIgnoreCase).ToArray()) { + UserId = userId, UsesScopedPermissions = access.UsesScopedPermissions, ScopedPermissionGrants = access.ScopedPermissionGrants .OrderBy(grant => grant.OrganizationId, StringComparer.OrdinalIgnoreCase) diff --git a/src/Helpdesk.AgentClient/HelpdeskAgentClient.cs b/src/Helpdesk.AgentClient/HelpdeskAgentClient.cs index aef05a72..5c108908 100644 --- a/src/Helpdesk.AgentClient/HelpdeskAgentClient.cs +++ b/src/Helpdesk.AgentClient/HelpdeskAgentClient.cs @@ -111,7 +111,7 @@ public async Task GetHealthAsync(CancellationToken cancellationToken { ("live", "/health/live", false), ("ready", "/health/ready", false), - ("auth", string.Equals(Configuration.Resolve().CredentialMode, "integration", StringComparison.Ordinal) ? "/api/v1/auth/me" : "/api/v1/auth/ai-agent/status", true) + ("auth", UsesCurrentUserEndpoint(Configuration.Resolve().CredentialMode) ? "/api/v1/auth/me" : "/api/v1/auth/ai-agent/status", true) }) { try @@ -148,6 +148,10 @@ public async Task GetHealthAsync(CancellationToken cancellationToken return values; } + private static bool UsesCurrentUserEndpoint(string credentialMode) => + string.Equals(credentialMode, "integration", StringComparison.OrdinalIgnoreCase) || + string.Equals(credentialMode, "gateway", StringComparison.OrdinalIgnoreCase); + public async Task GetAccessTokenAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/Helpdesk.Mcp.Core/Tools/HelpdeskTools.cs b/src/Helpdesk.Mcp.Core/Tools/HelpdeskTools.cs index bc3ccc41..95d4db5d 100644 --- a/src/Helpdesk.Mcp.Core/Tools/HelpdeskTools.cs +++ b/src/Helpdesk.Mcp.Core/Tools/HelpdeskTools.cs @@ -99,7 +99,7 @@ public async Task helpdesk_logs(string operation = "search return await Execute(() => client.GetAsync(Query("/api/v1/ops/ai-agent/logs", payload), true, cancellationToken), "Operational diagnostics read.").ConfigureAwait(false); } - [McpServerTool(UseStructuredContent = true), Description("Manage incidents. Read operations: list, get, peek, timeline, timeline_count, attachments_count, listeners_count. Confirmed mutations: create, bulk_create, update, delete, state, bulk_state, assign, assign_self, add_worklog, close. assign_self resolves the configured agent user only after confirmation. close requires request.incidentId and request.closureNote, writes an internal closure note by default, then sets the incident state to Resolved. Every mutation requires confirm:true.")] + [McpServerTool(UseStructuredContent = true), Description("Manage incidents. Read operations: list, get, peek, timeline, timeline_count, attachments_count, listeners_count. Confirmed mutations: create, bulk_create, update, delete, state, bulk_state, assign, assign_self, add_worklog, close. In caller-bound mode, assign_self uses the authenticated application identity; external agents use their configured agent identity. close requires request.incidentId and request.closureNote, writes an internal closure note by default, then sets the incident state to Resolved. Every mutation requires confirm:true.")] public Task helpdesk_incidents(string operation, JsonElement? request = null, bool confirm = false, CancellationToken cancellationToken = default) => TicketOperation("helpdesk_incidents", "/api/v1/incidents", "incidentId", operation, request, confirm, cancellationToken); [McpServerTool(UseStructuredContent = true), Description("Manage service requests. Reads: list, get, tasks, timeline, timeline_count, attachments_count, listeners_count. Confirmed mutations: create, bulk_create, update, delete, state, bulk_state, assign, assign_self, add_worklog. state requires request.requestId and request.newState; bulk_state requires request.ids and request.newState; assign requires request.ids and request.assignedToId. Every mutation requires confirm:true.")] public Task helpdesk_requests(string operation, JsonElement? request = null, bool confirm = false, CancellationToken cancellationToken = default) => TicketOperation("helpdesk_requests", "/api/v1/requests", "requestId", operation, request, confirm, cancellationToken); @@ -387,20 +387,34 @@ private async Task AssignTicketsToConfiguredAgent(string o { var ids = Strings(request, "ids"); if (ids.Count == 0) return Validation("request.ids must contain at least one ticket identifier."); - var email = request?["agentUserEmail"]?.ToString() ?? client.Configuration.AgentUserEmail; - if (string.IsNullOrWhiteSpace(email)) return Validation("request.agentUserEmail or RATELDESK_AGENT_USER_EMAIL is required for assign_self."); - if (!confirm) return Response(false, "confirmation_required", $"This would assign {ids.Count} tickets to the configured agent user.", AffectedIds: ids, Confirmation: new HelpdeskConfirmation("confirm", true, operation, ids)); + if (UsesCallerBoundIdentity && request?["agentUserEmail"] is not null) + return Validation("request.agentUserEmail is not permitted for caller-bound credentials; assign_self uses the authenticated application identity."); + if (!confirm) return Response(false, "confirmation_required", $"This would assign {ids.Count} tickets to the {(UsesCallerBoundIdentity ? "authenticated caller" : "configured agent user")}.", AffectedIds: ids, Confirmation: new HelpdeskConfirmation("confirm", true, operation, ids)); try { - var user = await client.GetAsync($"/api/v1/users/by-email/{Uri.EscapeDataString(email)}", true, ct).ConfigureAwait(false) as JsonObject; - var userId = user?["id"]?.ToString(); - if (string.IsNullOrWhiteSpace(userId)) return Validation($"Configured agent user '{email}' could not be resolved."); + var userId = UsesCallerBoundIdentity + ? (await client.GetAsync("/api/v1/auth/me", true, ct).ConfigureAwait(false) as JsonObject)?["userId"]?.ToString() + : await ResolveConfiguredAgentUserIdAsync(request, ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId)) + return Validation(UsesCallerBoundIdentity + ? "The authenticated caller is not linked to an application user." + : "Configured agent user could not be resolved."); var body = new JsonObject { ["ids"] = JsonSerializer.SerializeToNode(ids), ["assignedToId"] = userId }; return await Mutation("assign", [.. ids, userId], true, HttpMethod.Post, basePath + "/bulk/assign", body, ct).ConfigureAwait(false); } catch (AgentClientRemoteException ex) { return Response(false, Status(ex.StatusCode), ex.Message, AffectedIds: ids, Failure: new HelpdeskToolFailure(ex.Code, ex.StatusCode >= 500, ex.StatusCode)); } } + private async Task ResolveConfiguredAgentUserIdAsync(JsonObject? request, CancellationToken ct) + { + var email = request?["agentUserEmail"]?.ToString() ?? client.Configuration.AgentUserEmail; + if (string.IsNullOrWhiteSpace(email)) + return null; + + var user = await client.GetAsync($"/api/v1/users/by-email/{Uri.EscapeDataString(email)}", true, ct).ConfigureAwait(false) as JsonObject; + return user?["id"]?.ToString(); + } + private async Task DomainTicketCount(string tool, string basePath, string idName, string operation, JsonElement? request, CancellationToken ct) { var id = String(request, idName); @@ -696,9 +710,11 @@ private static bool TryRequest(JsonElement? request, out T value, out string private static string? String(JsonElement? element, string property) => element is { ValueKind: JsonValueKind.Object } value && value.TryGetProperty(property, out var node) && node.ValueKind == JsonValueKind.String ? node.GetString() : null; private static bool IsHttpUrl(string? value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"; private static string Query(string path, JsonObject? values) => values is null || values.Count == 0 ? path : path + "?" + string.Join("&", values.Where(x => x.Value is not null && x.Key is not "id" && !x.Key.EndsWith("Id", StringComparison.Ordinal)).Select(x => Uri.EscapeDataString(x.Key) + "=" + Uri.EscapeDataString(x.Value!.ToString()))); - private string AuthStatusPath => string.Equals(client.Configuration?.CredentialMode, "integration", StringComparison.OrdinalIgnoreCase) + private string AuthStatusPath => UsesCallerBoundIdentity ? "/api/v1/auth/me" : "/api/v1/auth/ai-agent/status"; + private bool UsesCallerBoundIdentity => string.Equals(client.Configuration?.CredentialMode, "integration", StringComparison.OrdinalIgnoreCase) || + string.Equals(client.Configuration?.CredentialMode, "gateway", StringComparison.OrdinalIgnoreCase); private JsonNode Capabilities() => new JsonObject { ["server"] = "Helpdesk.Mcp", ["instance"] = hostContext.Instance, ["catalogRevision"] = hostContext.CatalogRevision, ["transport"] = hostContext.Transport, ["resourceUri"] = hostContext.ResourceUri, ["apiBaseUrl"] = hostContext.CanonicalApiBaseUrl, ["credentialMode"] = client.Configuration?.CredentialMode ?? "authentik", ["authStatusPath"] = AuthStatusPath, ["phase"] = 3, ["mutationsEnabled"] = true, ["rawEnabled"] = false, ["mutationConfirmationRequired"] = true, ["configurationWritesEnabled"] = configurationSurface.CanPersist, ["obsoleteMutationProofToolExposed"] = false }; } diff --git a/src/Helpdesk.Shared/DTOs/Auth/CurrentUserAccessDto.cs b/src/Helpdesk.Shared/DTOs/Auth/CurrentUserAccessDto.cs index bc24c5d7..9efb6516 100644 --- a/src/Helpdesk.Shared/DTOs/Auth/CurrentUserAccessDto.cs +++ b/src/Helpdesk.Shared/DTOs/Auth/CurrentUserAccessDto.cs @@ -15,6 +15,9 @@ public sealed record CurrentUserAccessDto( IReadOnlyList AllowedOrganizationIds, IReadOnlyList ManagedOrganizationIds) { + /// Stable application identity of the authenticated caller, when one is linked. + public string? UserId { get; init; } + public bool UsesScopedPermissions { get; init; } public IReadOnlyList ScopedPermissionGrants { get; init; } = []; diff --git a/tests/Helpdesk.Tests/Api/LocalAuthenticationEndpointsTests.cs b/tests/Helpdesk.Tests/Api/LocalAuthenticationEndpointsTests.cs index 7b593546..690fd3c7 100644 --- a/tests/Helpdesk.Tests/Api/LocalAuthenticationEndpointsTests.cs +++ b/tests/Helpdesk.Tests/Api/LocalAuthenticationEndpointsTests.cs @@ -113,6 +113,7 @@ public async Task Local_login_issues_a_cookie_that_authenticates_subsequent_api_ Assert.Equal(HttpStatusCode.OK, currentUser.StatusCode); var access = await currentUser.Content.ReadFromJsonAsync(); Assert.True(access!.IsHelpdeskAdmin); + Assert.NotNull(access.UserId); } [Fact] diff --git a/tests/Helpdesk.Tests/Mcp/HelpdeskToolsMutationTests.cs b/tests/Helpdesk.Tests/Mcp/HelpdeskToolsMutationTests.cs index 425a6c6f..1102d94c 100644 --- a/tests/Helpdesk.Tests/Mcp/HelpdeskToolsMutationTests.cs +++ b/tests/Helpdesk.Tests/Mcp/HelpdeskToolsMutationTests.cs @@ -48,6 +48,25 @@ public async Task Integration_credential_auth_status_uses_application_identity_e await client.DidNotReceive().GetAsync("/api/v1/auth/ai-agent/status", true, Arg.Any()); } + [Fact] + public async Task Gateway_credential_auth_status_uses_application_identity_endpoint() + { + var client = Substitute.For(); + client.Configuration.Returns(new AgentClientConfiguration("https://api.example", null, null, null, null, null, null) + { + CredentialMode = "gateway" + }); + client.GetAsync("/api/v1/auth/me", true, Arg.Any()) + .Returns(new JsonObject { ["authMode"] = "integration" }); + var tools = new HelpdeskTools(client, Store()); + + var response = await tools.helpdesk_auth(cancellationToken: CancellationToken.None); + + Assert.True(response.Success); + await client.Received(1).GetAsync("/api/v1/auth/me", true, Arg.Any()); + await client.DidNotReceive().GetAsync("/api/v1/auth/ai-agent/status", true, Arg.Any()); + } + [Theory] [InlineData("helpdesk_incidents", "state", "{\"incidentId\":\"INC-123\",\"newState\":\"Resolved\"}")] [InlineData("helpdesk_requests", "assign", "{\"ids\":[\"REQ-123\"],\"assignedToId\":\"user-1\"}")] @@ -170,6 +189,53 @@ public async Task Assign_self_does_not_resolve_the_agent_before_confirmation() await client.DidNotReceive().GetAsync(Arg.Any(), Arg.Any(), Arg.Any()); await client.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } + + [Fact] + public async Task Caller_bound_assign_self_rejects_an_email_override_without_an_upstream_call() + { + var client = Substitute.For(); + client.Configuration.Returns(new AgentClientConfiguration("https://api.example", null, null, null, null, null, "operator@example.test") + { + CredentialMode = "integration", + IntegrationCredential = "rdk_test" + }); + var tools = new HelpdeskTools(client, Store()); + + var response = await tools.helpdesk_incidents("assign_self", Request("""{"ids":["INC-123"],"agentUserEmail":"other@example.test"}"""), confirm: true); + + Assert.Equal("validation_failed", response.Status); + Assert.Contains("not permitted", response.Summary, StringComparison.Ordinal); + await client.DidNotReceive().GetAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Caller_bound_assign_self_uses_the_authenticated_application_identity() + { + var client = Substitute.For(); + client.Configuration.Returns(new AgentClientConfiguration("https://api.example", null, null, null, null, null, "operator@example.test") + { + CredentialMode = "integration", + IntegrationCredential = "rdk_test" + }); + client.GetAsync("/api/v1/auth/me", true, Arg.Any()) + .Returns(new JsonObject { ["userId"] = "caller-user" }); + JsonNode? assignedBody = null; + client.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => + { + assignedBody = call.ArgAt(2); + return new JsonObject { ["id"] = "INC-123" }; + }); + var tools = new HelpdeskTools(client, Store()); + + var response = await tools.helpdesk_incidents("assign_self", Request("""{"ids":["INC-123"]}"""), confirm: true); + + Assert.True(response.Success); + await client.Received(1).GetAsync("/api/v1/auth/me", true, Arg.Any()); + await client.DidNotReceive().GetAsync(Arg.Is(path => path.StartsWith("/api/v1/users/by-email/", StringComparison.Ordinal)), Arg.Any(), Arg.Any()); + await client.Received(1).SendAsync(HttpMethod.Post, "/api/v1/incidents/bulk/assign", Arg.Any(), true, Arg.Any()); + Assert.Equal("caller-user", (assignedBody as JsonObject)?["assignedToId"]?.ToString()); + } [Fact] public async Task Close_incident_without_confirmation_does_not_call_the_api() { From 92988ed17d2dfe6d9574481ec0fdb92ad5abf8e4 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 15:46:02 +0200 Subject: [PATCH 15/32] fix(compose): secure MCP configuration handoff --- .github/workflows/pull-request-validation.yml | 4 ++ docker/docker-compose.mcp.release.yml | 29 +++++++++-- docker/docker-compose.mcp.yml | 31 ++++++++++-- docker/examples/mcp-http/README.md | 29 ++++++++--- .../mcp-http/config.authentik.example.json | 9 ++++ docker/examples/mcp-http/docker-compose.yml | 30 ++++++++++-- tools/ci/test-mcp-compose-config.sh | 49 +++++++++++++++++++ 7 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 docker/examples/mcp-http/config.authentik.example.json create mode 100755 tools/ci/test-mcp-compose-config.sh diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 9fa5f20c..59e47564 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -173,6 +173,10 @@ jobs: if: matrix.name == 'web' run: tools/ci/smoke-web-static-assets.sh "${{ matrix.image }}" + - name: Validate MCP Compose configuration ownership + if: matrix.name == 'mcp-http' + run: tools/ci/test-mcp-compose-config.sh "${{ matrix.image }}" + release-assets: name: Release asset rehearsal runs-on: ubuntu-latest diff --git a/docker/docker-compose.mcp.release.yml b/docker/docker-compose.mcp.release.yml index d55d20bb..48b439f4 100644 --- a/docker/docker-compose.mcp.release.yml +++ b/docker/docker-compose.mcp.release.yml @@ -2,6 +2,24 @@ name: rateldesk # Optional image-only overlay. It never creates a source build. services: + mcp-config-init: + image: ghcr.io/bostontechnologies/rateldesk-mcp-http:${RATELDESK_VERSION:?Set RATELDESK_VERSION to an exact published RatelDesk version.} + user: "0:0" + entrypoint: ["/bin/sh", "-ec"] + command: >- + mkdir -p /run/rateldesk-mcp && + cp /input/config.json /run/rateldesk-mcp/config.json && + chown 10001:10001 /run/rateldesk-mcp/config.json && + chmod 0400 /run/rateldesk-mcp/config.json + volumes: + - type: bind + source: ${RATELDESK_MCP_CONFIG_FILE:?Set RATELDESK_MCP_CONFIG_FILE to a protected configuration file.} + target: /input/config.json + read_only: true + - type: volume + source: mcp-http-config + target: /run/rateldesk-mcp + mcp-http: image: ghcr.io/bostontechnologies/rateldesk-mcp-http:${RATELDESK_VERSION:?Set RATELDESK_VERSION to an exact published RatelDesk version.} environment: @@ -17,12 +35,17 @@ services: Authentication__AuthentikMcp__RequiredScopes__0: ${RATELDESK_MCP_REQUIRED_SCOPE:-helpdesk.mcp} Authentication__AuthentikMcp__RequiredGroups__0: ${RATELDESK_MCP_REQUIRED_GROUP:-ai-assistant} volumes: - - type: bind - source: ${RATELDESK_MCP_CONFIG_FILE:?Set RATELDESK_MCP_CONFIG_FILE to a protected configuration file.} - target: /run/rateldesk-mcp/config.json + - type: volume + source: mcp-http-config + target: /run/rateldesk-mcp read_only: true depends_on: api: condition: service_healthy + mcp-config-init: + condition: service_completed_successfully ports: - "127.0.0.1:${RATELDESK_MCP_PORT:-8223}:8223" + +volumes: + mcp-http-config: diff --git a/docker/docker-compose.mcp.yml b/docker/docker-compose.mcp.yml index 648e2d43..e8ec1969 100644 --- a/docker/docker-compose.mcp.yml +++ b/docker/docker-compose.mcp.yml @@ -2,6 +2,26 @@ name: rateldesk # Optional source-build overlay. The base compose topology remains Web + API. services: + mcp-config-init: + build: + context: .. + dockerfile: docker/mcp-http/Dockerfile + user: "0:0" + entrypoint: ["/bin/sh", "-ec"] + command: >- + mkdir -p /run/rateldesk-mcp && + cp /input/config.json /run/rateldesk-mcp/config.json && + chown 10001:10001 /run/rateldesk-mcp/config.json && + chmod 0400 /run/rateldesk-mcp/config.json + volumes: + - type: bind + source: ${RATELDESK_MCP_CONFIG_FILE:?Set RATELDESK_MCP_CONFIG_FILE to a protected configuration file.} + target: /input/config.json + read_only: true + - type: volume + source: mcp-http-config + target: /run/rateldesk-mcp + mcp-http: build: context: .. @@ -19,12 +39,17 @@ services: Authentication__AuthentikMcp__RequiredScopes__0: ${RATELDESK_MCP_REQUIRED_SCOPE:-helpdesk.mcp} Authentication__AuthentikMcp__RequiredGroups__0: ${RATELDESK_MCP_REQUIRED_GROUP:-ai-assistant} volumes: - - type: bind - source: ${RATELDESK_MCP_CONFIG_FILE:?Set RATELDESK_MCP_CONFIG_FILE to a protected configuration file.} - target: /run/rateldesk-mcp/config.json + - type: volume + source: mcp-http-config + target: /run/rateldesk-mcp read_only: true depends_on: api: condition: service_healthy + mcp-config-init: + condition: service_completed_successfully ports: - "127.0.0.1:${RATELDESK_MCP_PORT:-8223}:8223" + +volumes: + mcp-http-config: diff --git a/docker/examples/mcp-http/README.md b/docker/examples/mcp-http/README.md index 0868f2d3..1c608f28 100644 --- a/docker/examples/mcp-http/README.md +++ b/docker/examples/mcp-http/README.md @@ -19,22 +19,37 @@ Set `RATELDESK_API_BASE_URL` to the exact API target, `RATELDESK_MCP_PUBLIC_RESOURCE_URI` to the public HTTPS `/mcp` URL. Configure `RATELDESK_MCP_ALLOWED_ORIGIN` to the exact browser origin (not a path). The MCP container receives only its protected configuration file and does not -mount the API database or data-protection key ring. +mount the API database or data-protection key ring. The one-shot +`mcp-config-init` service reads the host file as root, copies it into a named +volume with owner `10001:10001` and mode `0400`, then exits. The running MCP +gateway remains non-root and mounts only that copied file read-only. For an external OAuth deployment, use the source or release compose overlay -with `Helpdesk__Mcp__AuthenticationMode=authentik` and its required -`Authentication__AuthentikMcp__*` settings. Authentik mode is explicit and is -never used as a fallback for local paired credentials. +with `Helpdesk__Mcp__AuthenticationMode=authentik`. Copy +`config.authentik.example.json` to a protected file and replace every +placeholder: it supplies the downstream service-account configuration, while +the Compose settings below supply the separate ingress issuer, audience, +scope, and group checks. Authentik mode is explicit and is never used as a +fallback for local paired credentials. For a source checkout, run from the repository root: ```sh -docker compose -f docker/docker-compose.yml -f docker/docker-compose.mcp.yml up --build +cp docker/examples/mcp-http/config.authentik.example.json config.authentik.json +chmod 600 config.authentik.json +RATELDESK_MCP_CONFIG_FILE="$PWD/config.authentik.json" \ +RATELDESK_MCP_AUTHENTIK_AUTHORITY=https://auth.example.test/ \ +docker compose --env-file docker/examples/mcp-http/.env \ + -f docker/docker-compose.yml -f docker/docker-compose.mcp.yml up --build ``` For release images, supply the one selected version to both files: ```sh -RATELDESK_VERSION=0.1.0-rc.9 \ - docker compose -f docker/docker-compose.release.yml -f docker/docker-compose.mcp.release.yml up -d +cp docker/examples/mcp-http/config.authentik.example.json config.authentik.json +chmod 600 config.authentik.json +RATELDESK_MCP_CONFIG_FILE="$PWD/config.authentik.json" \ +RATELDESK_MCP_AUTHENTIK_AUTHORITY=https://auth.example.test/ \ +docker compose --env-file docker/examples/mcp-http/.env \ + -f docker/docker-compose.release.yml -f docker/docker-compose.mcp.release.yml up -d ``` diff --git a/docker/examples/mcp-http/config.authentik.example.json b/docker/examples/mcp-http/config.authentik.example.json new file mode 100644 index 00000000..3166b9b1 --- /dev/null +++ b/docker/examples/mcp-http/config.authentik.example.json @@ -0,0 +1,9 @@ +{ + "apiBaseUrl": "http://api:8222/", + "credentialMode": "authentik", + "authentikTokenUrl": "https://auth.example.test/application/o/token/", + "authentikClientId": "replace-with-the-mcp-client-id", + "authentikUsername": "replace-with-the-mcp-service-account", + "authentikAppPassword": "replace-with-the-mcp-service-account-password", + "authentikScope": "openid profile email" +} diff --git a/docker/examples/mcp-http/docker-compose.yml b/docker/examples/mcp-http/docker-compose.yml index f78cba1f..e9de8b44 100644 --- a/docker/examples/mcp-http/docker-compose.yml +++ b/docker/examples/mcp-http/docker-compose.yml @@ -2,6 +2,24 @@ name: rateldesk-mcp-http # Connects to an already-running API; it deliberately has no depends_on entry. services: + mcp-config-init: + image: ghcr.io/bostontechnologies/rateldesk-mcp-http:${RATELDESK_VERSION:?Set RATELDESK_VERSION to an exact published RatelDesk version.} + user: "0:0" + entrypoint: ["/bin/sh", "-ec"] + command: >- + mkdir -p /run/rateldesk-mcp && + cp /input/config.json /run/rateldesk-mcp/config.json && + chown 10001:10001 /run/rateldesk-mcp/config.json && + chmod 0400 /run/rateldesk-mcp/config.json + volumes: + - type: bind + source: ${RATELDESK_MCP_CONFIG_FILE:?Set the protected MCP configuration file.} + target: /input/config.json + read_only: true + - type: volume + source: mcp-http-config + target: /run/rateldesk-mcp + mcp-http: image: ghcr.io/bostontechnologies/rateldesk-mcp-http:${RATELDESK_VERSION:?Set RATELDESK_VERSION to an exact published RatelDesk version.} environment: @@ -13,9 +31,15 @@ services: Helpdesk__Mcp__AllowedOrigins__0: ${RATELDESK_MCP_ALLOWED_ORIGIN:?Set the browser origin allowed to call this MCP endpoint.} Helpdesk__Mcp__AuthenticationMode: gateway volumes: - - type: bind - source: ${RATELDESK_MCP_CONFIG_FILE:?Set the protected MCP configuration file.} - target: /run/rateldesk-mcp/config.json + - type: volume + source: mcp-http-config + target: /run/rateldesk-mcp read_only: true + depends_on: + mcp-config-init: + condition: service_completed_successfully ports: - "127.0.0.1:${RATELDESK_MCP_PORT:-8223}:8223" + +volumes: + mcp-http-config: diff --git a/tools/ci/test-mcp-compose-config.sh b/tools/ci/test-mcp-compose-config.sh new file mode 100755 index 00000000..7f3488f6 --- /dev/null +++ b/tools/ci/test-mcp-compose-config.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +image=${1:?Usage: test-mcp-compose-config.sh } +work_directory=$(mktemp -d) +project_name="rateldesk-mcp-config-${RANDOM}${RANDOM}" +image_tag="compose-validation-${RANDOM}${RANDOM}" +mcp_image="ghcr.io/bostontechnologies/rateldesk-mcp-http:$image_tag" +config_file="$work_directory/config.json" +standalone_compose=( + -p "$project_name" + -f docker/examples/mcp-http/docker-compose.yml +) + +cleanup() { + docker compose "${standalone_compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + docker image rm "$mcp_image" >/dev/null 2>&1 || true + rm -rf "$work_directory" +} +trap cleanup EXIT + +printf '%s\n' '{"apiBaseUrl":"https://api.example.test/","credentialMode":"gateway"}' > "$config_file" +chmod 0600 "$config_file" + +compose_environment=( + "RATELDESK_VERSION=$image_tag" + "RATELDESK_MCP_CONFIG_FILE=$config_file" + 'RATELDESK_API_BASE_URL=https://api.example.test/' + 'RATELDESK_MCP_PUBLIC_RESOURCE_URI=https://mcp.example.test/mcp' + 'RATELDESK_MCP_ALLOWED_ORIGIN=https://client.example.test' + 'RATELDESK_MCP_AUTHENTIK_AUTHORITY=https://auth.example.test/' + 'RATELDESK_MCP_PORT=18223' +) + +docker tag "$image" "$mcp_image" + +env "${compose_environment[@]}" docker compose "${standalone_compose[@]}" config --quiet +env "${compose_environment[@]}" docker compose -f docker/docker-compose.yml -f docker/docker-compose.mcp.yml config --quiet +env "${compose_environment[@]}" docker compose -f docker/docker-compose.release.yml -f docker/docker-compose.mcp.release.yml config --quiet + +env "${compose_environment[@]}" docker compose "${standalone_compose[@]}" up --detach --wait +curl --retry 6 --retry-all-errors --retry-delay 1 --fail --show-error --silent \ + http://127.0.0.1:18223/health/live > /dev/null +env "${compose_environment[@]}" docker compose "${standalone_compose[@]}" exec -T mcp-http /bin/sh -ec ' + test "$(id -u)" = 10001 + test -r /run/rateldesk-mcp/config.json + test "$(stat -c "%u:%g:%a" /run/rateldesk-mcp/config.json)" = "10001:10001:400" + grep -Fqx "{\"apiBaseUrl\":\"https://api.example.test/\",\"credentialMode\":\"gateway\"}" /run/rateldesk-mcp/config.json + ' From 80b1f14327afc9f11fca4713a7e288788d6f9975 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 15:49:01 +0200 Subject: [PATCH 16/32] test(compose): retain MCP initializer failure logs --- tools/ci/test-mcp-compose-config.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/ci/test-mcp-compose-config.sh b/tools/ci/test-mcp-compose-config.sh index 7f3488f6..2e546f23 100755 --- a/tools/ci/test-mcp-compose-config.sh +++ b/tools/ci/test-mcp-compose-config.sh @@ -13,9 +13,14 @@ standalone_compose=( ) cleanup() { + local status=$? + if [[ "$status" -ne 0 ]]; then + docker compose "${standalone_compose[@]}" logs --no-color >&2 || true + fi docker compose "${standalone_compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true docker image rm "$mcp_image" >/dev/null 2>&1 || true rm -rf "$work_directory" + exit "$status" } trap cleanup EXIT From 9bb565b06ae5fe094fce2757be2825c5f1bc9fd8 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 15:51:27 +0200 Subject: [PATCH 17/32] fix(compose): preserve MCP probe configuration in cleanup --- tools/ci/test-mcp-compose-config.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/ci/test-mcp-compose-config.sh b/tools/ci/test-mcp-compose-config.sh index 2e546f23..9078db1a 100755 --- a/tools/ci/test-mcp-compose-config.sh +++ b/tools/ci/test-mcp-compose-config.sh @@ -11,13 +11,14 @@ standalone_compose=( -p "$project_name" -f docker/examples/mcp-http/docker-compose.yml ) +compose_environment=() cleanup() { local status=$? if [[ "$status" -ne 0 ]]; then - docker compose "${standalone_compose[@]}" logs --no-color >&2 || true + env "${compose_environment[@]}" docker compose "${standalone_compose[@]}" logs --no-color >&2 || true fi - docker compose "${standalone_compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + env "${compose_environment[@]}" docker compose "${standalone_compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true docker image rm "$mcp_image" >/dev/null 2>&1 || true rm -rf "$work_directory" exit "$status" From 26a22fd9007a06f67835d67766fe8a8d54e302ce Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 15:53:35 +0200 Subject: [PATCH 18/32] fix(compose): preserve MCP initializer command arguments --- docker/docker-compose.mcp.release.yml | 11 ++++++----- docker/docker-compose.mcp.yml | 11 ++++++----- docker/examples/mcp-http/docker-compose.yml | 11 ++++++----- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/docker/docker-compose.mcp.release.yml b/docker/docker-compose.mcp.release.yml index 48b439f4..3cb49250 100644 --- a/docker/docker-compose.mcp.release.yml +++ b/docker/docker-compose.mcp.release.yml @@ -6,11 +6,12 @@ services: image: ghcr.io/bostontechnologies/rateldesk-mcp-http:${RATELDESK_VERSION:?Set RATELDESK_VERSION to an exact published RatelDesk version.} user: "0:0" entrypoint: ["/bin/sh", "-ec"] - command: >- - mkdir -p /run/rateldesk-mcp && - cp /input/config.json /run/rateldesk-mcp/config.json && - chown 10001:10001 /run/rateldesk-mcp/config.json && - chmod 0400 /run/rateldesk-mcp/config.json + command: + - >- + mkdir -p /run/rateldesk-mcp && + cp /input/config.json /run/rateldesk-mcp/config.json && + chown 10001:10001 /run/rateldesk-mcp/config.json && + chmod 0400 /run/rateldesk-mcp/config.json volumes: - type: bind source: ${RATELDESK_MCP_CONFIG_FILE:?Set RATELDESK_MCP_CONFIG_FILE to a protected configuration file.} diff --git a/docker/docker-compose.mcp.yml b/docker/docker-compose.mcp.yml index e8ec1969..c4e0c0df 100644 --- a/docker/docker-compose.mcp.yml +++ b/docker/docker-compose.mcp.yml @@ -8,11 +8,12 @@ services: dockerfile: docker/mcp-http/Dockerfile user: "0:0" entrypoint: ["/bin/sh", "-ec"] - command: >- - mkdir -p /run/rateldesk-mcp && - cp /input/config.json /run/rateldesk-mcp/config.json && - chown 10001:10001 /run/rateldesk-mcp/config.json && - chmod 0400 /run/rateldesk-mcp/config.json + command: + - >- + mkdir -p /run/rateldesk-mcp && + cp /input/config.json /run/rateldesk-mcp/config.json && + chown 10001:10001 /run/rateldesk-mcp/config.json && + chmod 0400 /run/rateldesk-mcp/config.json volumes: - type: bind source: ${RATELDESK_MCP_CONFIG_FILE:?Set RATELDESK_MCP_CONFIG_FILE to a protected configuration file.} diff --git a/docker/examples/mcp-http/docker-compose.yml b/docker/examples/mcp-http/docker-compose.yml index e9de8b44..268ae043 100644 --- a/docker/examples/mcp-http/docker-compose.yml +++ b/docker/examples/mcp-http/docker-compose.yml @@ -6,11 +6,12 @@ services: image: ghcr.io/bostontechnologies/rateldesk-mcp-http:${RATELDESK_VERSION:?Set RATELDESK_VERSION to an exact published RatelDesk version.} user: "0:0" entrypoint: ["/bin/sh", "-ec"] - command: >- - mkdir -p /run/rateldesk-mcp && - cp /input/config.json /run/rateldesk-mcp/config.json && - chown 10001:10001 /run/rateldesk-mcp/config.json && - chmod 0400 /run/rateldesk-mcp/config.json + command: + - >- + mkdir -p /run/rateldesk-mcp && + cp /input/config.json /run/rateldesk-mcp/config.json && + chown 10001:10001 /run/rateldesk-mcp/config.json && + chmod 0400 /run/rateldesk-mcp/config.json volumes: - type: bind source: ${RATELDESK_MCP_CONFIG_FILE:?Set the protected MCP configuration file.} From 1109f8b7395f69ed98ed00ec68f3aac9d80f3719 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 16:23:02 +0200 Subject: [PATCH 19/32] fix(credentials): scope grants to organizations --- .../IntegrationCredentialEndpoints.cs | 65 +++++++++++++++---- src/Helpdesk.API/Program.cs | 5 ++ .../Auth/Rbac/CurrentUserAccessService.cs | 65 ++++++++++++++----- .../Api/IntegrationCredentialSqliteTests.cs | 51 ++++++++++++++- .../CurrentUserAccessServiceTests.cs | 64 ++++++++++++++++++ 5 files changed, 215 insertions(+), 35 deletions(-) diff --git a/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs b/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs index ac868de9..009f9ff9 100644 --- a/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs +++ b/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs @@ -3,6 +3,7 @@ using System.Text; using Helpdesk.API.Authentication; using Helpdesk.Infrastructure.Identity; +using Helpdesk.Infrastructure.Persistence; using Helpdesk.Shared.Auth; using Helpdesk.Shared.Services; using Microsoft.AspNetCore.Mvc; @@ -13,6 +14,7 @@ namespace Helpdesk.API.Endpoints.Authentication; public static class IntegrationCredentialEndpoints { public const string CredentialManagementPolicy = "IntegrationCredentialManagementSession"; + public const string SelfRevocationPolicy = "IntegrationCredentialSelfRevocation"; private const int DefaultLifetimeDays = 30; private const int MaximumLifetimeDays = 90; @@ -47,6 +49,7 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder HttpContext context, IIntegrationCredentialOwnerResolver ownerResolver, ICurrentUserAccessService accessService, + HelpdeskDbContext db, RatelDeskIdentityDbContext identityDb, CancellationToken ct) => { @@ -54,29 +57,45 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder if (owner is null) return Results.Forbid(); if (request is null) return Results.ValidationProblem(new Dictionary { ["request"] = ["A credential request is required."] }); if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 128) return Results.ValidationProblem(new Dictionary { ["name"] = ["A credential name up to 128 characters is required."] }); - if (request.Purpose is not ("api" or "mcp")) return Results.ValidationProblem(new Dictionary { ["purpose"] = ["Purpose must be api or mcp."] }); - var mcpResourceUri = request.Purpose == "mcp" + var purpose = request.Purpose?.Trim().ToLowerInvariant(); + if (purpose is not ("api" or "mcp")) return Results.ValidationProblem(new Dictionary { ["purpose"] = ["Purpose must be api or mcp."] }); + var mcpResourceUri = purpose == "mcp" ? CanonicalMcpResourceUri(request.McpResourceUri) : null; - if (request.Purpose == "mcp" && mcpResourceUri is null) + if (purpose == "mcp" && mcpResourceUri is null) return Results.ValidationProblem(new Dictionary { ["mcpResourceUri"] = ["MCP credentials require an absolute HTTPS resource URI ending in /mcp."] }); - if (request.Purpose == "api" && !string.IsNullOrWhiteSpace(request.McpResourceUri)) + if (purpose == "api" && !string.IsNullOrWhiteSpace(request.McpResourceUri)) return Results.ValidationProblem(new Dictionary { ["mcpResourceUri"] = ["MCP resource URIs can only be configured for MCP credentials."] }); var access = await accessService.ResolveAsync(principal, ct); - var requestedPermissions = request.Permissions? - .Where(permission => !string.IsNullOrWhiteSpace(permission)) + if (request.Permissions is null || request.Permissions.Count == 0 || + request.Permissions.Any(string.IsNullOrWhiteSpace)) + { + return Results.ValidationProblem(new Dictionary { ["permissions"] = ["At least one non-empty permission is required."] }); + } + var requestedPermissions = request.Permissions .Select(permission => permission.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray() ?? []; - if (requestedPermissions.Length == 0 || requestedPermissions.Except(access.Permissions, StringComparer.OrdinalIgnoreCase).Any() || - requestedPermissions.Any(permission => !HelpdeskPermissions.AssignablePermissions.Contains(permission, StringComparer.OrdinalIgnoreCase))) + .ToArray(); + if (requestedPermissions.Any(permission => !HelpdeskPermissions.AssignablePermissions.Contains(permission, StringComparer.OrdinalIgnoreCase))) { - return Results.Forbid(); + return Results.ValidationProblem(new Dictionary { ["permissions"] = ["Permissions must be known assignable permissions."] }); + } + var organizationId = request.OrganizationId?.Trim(); + var organization = string.IsNullOrWhiteSpace(organizationId) + ? null + : await db.Organizations.AsNoTracking() + .SingleOrDefaultAsync(candidate => candidate.Id == organizationId, ct); + if (organization is null || organization.State != Helpdesk.Shared.Models.EntityState.Enabled) + { + return Results.ValidationProblem(new Dictionary { ["organizationId"] = ["An enabled organization is required."] }); } - if (string.IsNullOrWhiteSpace(request.OrganizationId) || !access.AllowedOrganizationIds.Contains(request.OrganizationId)) return Results.Forbid(); + if (!access.IsHelpdeskAdmin && requestedPermissions.Any(permission => !access.HasPermission(permission, organizationId))) + return Results.Forbid(); - var lifetimeDays = Math.Clamp(request.LifetimeDays ?? DefaultLifetimeDays, 1, MaximumLifetimeDays); + if (request.LifetimeDays is < 1 or > MaximumLifetimeDays) + return Results.ValidationProblem(new Dictionary { ["lifetimeDays"] = [$"Lifetime must be between 1 and {MaximumLifetimeDays} days."] }); + var lifetimeDays = request.LifetimeDays ?? DefaultLifetimeDays; var id = Guid.NewGuid(); var secret = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); var prefix = $"rdk_{id:N}"[..16]; @@ -88,9 +107,9 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder Name = request.Name.Trim(), Prefix = prefix, SecretHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(secret))), - Purpose = request.Purpose, + Purpose = purpose, McpResourceUri = mcpResourceUri, - OrganizationId = request.OrganizationId, + OrganizationId = organizationId, Permissions = string.Join(' ', requestedPermissions.Order(StringComparer.OrdinalIgnoreCase)), CreatedAtUtc = createdAtUtc, CreatedAtUnixMilliseconds = createdAtUtc.ToUnixTimeMilliseconds(), @@ -120,6 +139,24 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder } return Results.NoContent(); }).WithSummary("Revoke an integration credential"); + + app.MapPost("/api/v1/integration-credentials/self/revoke", async ( + ClaimsPrincipal principal, + RatelDeskIdentityDbContext identityDb, + CancellationToken ct) => + { + if (!Guid.TryParseExact(principal.FindFirstValue("integration_credential_id"), "N", out var credentialId)) + return Results.Forbid(); + + var credential = await identityDb.IntegrationCredentials.SingleOrDefaultAsync(candidate => candidate.Id == credentialId, ct); + if (credential is null) return Results.NotFound(); + if (credential.RevokedAtUtc is null) + { + credential.RevokedAtUtc = DateTimeOffset.UtcNow; + await identityDb.SaveChangesAsync(ct); + } + return Results.NoContent(); + }).RequireAuthorization(SelfRevocationPolicy).WithSummary("Revoke the presenting integration credential"); } private static string? CanonicalMcpResourceUri(string? value) diff --git a/src/Helpdesk.API/Program.cs b/src/Helpdesk.API/Program.cs index 6642f37f..9e638e05 100644 --- a/src/Helpdesk.API/Program.cs +++ b/src/Helpdesk.API/Program.cs @@ -964,6 +964,11 @@ await OrchestrationCallbackEndpoints.PublishRejectedAsync( policy.RequireAuthenticatedUser(); policy.AddRequirements(new IntegrationCredentialManagementSessionRequirement()); }); + opts.AddPolicy(IntegrationCredentialEndpoints.SelfRevocationPolicy, policy => + { + policy.AddAuthenticationSchemes(IntegrationCredentialAuthenticationHandler.SchemeName); + policy.RequireAuthenticatedUser(); + }); opts.AddPolicy("HelpdeskAdmin", p => { diff --git a/src/Helpdesk.Infrastructure/Auth/Rbac/CurrentUserAccessService.cs b/src/Helpdesk.Infrastructure/Auth/Rbac/CurrentUserAccessService.cs index e477d193..43a5e6f1 100644 --- a/src/Helpdesk.Infrastructure/Auth/Rbac/CurrentUserAccessService.cs +++ b/src/Helpdesk.Infrastructure/Auth/Rbac/CurrentUserAccessService.cs @@ -31,9 +31,18 @@ public async Task ResolveAsync(ClaimsPrincipal user, C return Empty(false); } - var localAccountId = IsLocalAccount(user) + var claimedLocalAccountId = IsLocalAccount(user) ? user.FindFirstValue(ClaimTypes.NameIdentifier) : null; + var email = FirstClaim(user, ClaimTypes.Email, "email", "preferred_username"); + var issuer = FirstClaim(user, "iss")?.TrimEnd('/'); + var subject = FirstClaim(user, "sub"); + var authentikUserId = FirstClaim(user, "authentik_user_id", "ak_user_id"); + var link = await FindCustomerAuthLinkAsync(claimedLocalAccountId, issuer, subject, authentikUserId, ct); + // A linked OIDC subject is not an application user ID. Resolve its + // persisted application account before evaluating account state or + // scoped role assignments. + var localAccountId = claimedLocalAccountId ?? link?.LocalAccountId; ApplicationUser? localAccount = null; if (_identityDb is not null && !string.IsNullOrWhiteSpace(localAccountId)) { @@ -82,12 +91,6 @@ public async Task ResolveAsync(ClaimsPrincipal user, C } } - var email = FirstClaim(user, ClaimTypes.Email, "email", "preferred_username"); - var issuer = FirstClaim(user, "iss")?.TrimEnd('/'); - var subject = FirstClaim(user, "sub"); - var authentikUserId = FirstClaim(user, "authentik_user_id", "ak_user_id"); - - var link = await FindCustomerAuthLinkAsync(localAccountId, issuer, subject, authentikUserId, ct); Customer? customer = null; Organization? organization = null; if (link is not null) @@ -100,7 +103,7 @@ public async Task ResolveAsync(ClaimsPrincipal user, C } var hasActiveCustomer = customer?.IsEnabled == true && organization?.IsEnabled == true; - var domainUserId = localAccountId ?? link?.DomainUserId; + var domainUserId = link?.DomainUserId ?? localAccountId; var domainUser = string.IsNullOrWhiteSpace(domainUserId) ? null : await _db.Users.AsNoTracking().FirstOrDefaultAsync(domainUser => domainUser.Id == domainUserId, ct); @@ -238,7 +241,7 @@ persistedRole.OwnerOrganizationId is not null && ScopedPermissionGrants = scopedPermissionGrants }; - return ConstrainIntegrationCredential(profile, user); + return await ConstrainIntegrationCredentialAsync(profile, user, ct); } private async Task FindCustomerAuthLinkAsync( @@ -275,7 +278,10 @@ persistedRole.OwnerOrganizationId is not null && private static bool IsLocalAccount(ClaimsPrincipal user) => user.FindFirstValue("auth_mode") is "local" or "integration"; - private static CurrentUserAccessProfile ConstrainIntegrationCredential(CurrentUserAccessProfile profile, ClaimsPrincipal user) + private async Task ConstrainIntegrationCredentialAsync( + CurrentUserAccessProfile profile, + ClaimsPrincipal user, + CancellationToken ct) { if (!string.Equals(user.FindFirstValue("auth_mode"), "integration", StringComparison.OrdinalIgnoreCase)) return profile; @@ -284,18 +290,41 @@ private static CurrentUserAccessProfile ConstrainIntegrationCredential(CurrentUs .Select(claim => claim.Value) .ToHashSet(StringComparer.OrdinalIgnoreCase); var requestedOrganizationId = user.FindFirstValue("integration_organization_id"); - if (requestedPermissions.Count == 0 || string.IsNullOrWhiteSpace(requestedOrganizationId)) + if (requestedPermissions.Count == 0 || string.IsNullOrWhiteSpace(requestedOrganizationId) || + requestedPermissions.Any(permission => !HelpdeskPermissions.AssignablePermissions.Contains(permission, StringComparer.OrdinalIgnoreCase))) + return Empty(true); + + HashSet effectiveGrants; + if (profile.IsHelpdeskAdmin) + { + var organization = await _db.Organizations.AsNoTracking() + .SingleOrDefaultAsync(candidate => candidate.Id == requestedOrganizationId, ct); + if (organization?.State != Helpdesk.Shared.Models.EntityState.Enabled) + return Empty(true); + + // Instance administration authorizes issuing a deliberately + // bounded credential, never an instance-wide administrator token. + effectiveGrants = requestedPermissions + .Select(permission => new ScopedPermissionGrant(permission, requestedOrganizationId)) + .ToHashSet(); + } + else + { + effectiveGrants = profile.ScopedPermissionGrants + .Where(grant => requestedPermissions.Contains(grant.Permission) && + string.Equals(grant.OrganizationId, requestedOrganizationId, StringComparison.OrdinalIgnoreCase)) + .ToHashSet(); + } + + if (effectiveGrants.Count == 0) return Empty(true); - var effectivePermissions = profile.Permissions - .Where(requestedPermissions.Contains) + var effectivePermissions = effectiveGrants + .Select(grant => grant.Permission) .ToHashSet(StringComparer.OrdinalIgnoreCase); - var effectiveOrganizations = profile.AllowedOrganizationIds - .Where(id => string.Equals(id, requestedOrganizationId, StringComparison.OrdinalIgnoreCase)) + var effectiveOrganizations = effectiveGrants + .Select(grant => grant.OrganizationId) .ToHashSet(StringComparer.OrdinalIgnoreCase); - var effectiveGrants = profile.ScopedPermissionGrants - .Where(grant => requestedPermissions.Contains(grant.Permission) && effectiveOrganizations.Contains(grant.OrganizationId)) - .ToHashSet(); return profile with { diff --git a/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs b/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs index 7b7d1c01..ce746264 100644 --- a/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs +++ b/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs @@ -5,6 +5,8 @@ using Helpdesk.API.Endpoints.Authentication; using Helpdesk.API.Authentication; using Helpdesk.Infrastructure.Identity; +using Helpdesk.Infrastructure.Persistence; +using Helpdesk.Shared.Models; using Helpdesk.Shared.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; @@ -15,6 +17,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -116,6 +119,26 @@ public async Task Create_endpoint_pairs_an_mcp_credential_to_its_canonical_resou Assert.Equal("https://helpdesk.example/mcp", credential.McpResourceUri); } + [Fact] + public async Task Create_endpoint_rejects_a_permission_from_another_organization() + { + await using var harness = await Harness.CreateAsync(); + + var response = await harness.Client.PostAsJsonAsync("/api/v1/integration-credentials/", new + { + name = "Wrong organization", + purpose = "api", + organizationId = "org-b", + permissions = new[] { "Incident.Write" }, + lifetimeDays = 30 + }); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + await using var scope = harness.Services.CreateAsyncScope(); + Assert.Empty(await scope.ServiceProvider.GetRequiredService() + .IntegrationCredentials.ToListAsync()); + } + private static IntegrationCredential Credential(string ownerId, string name, DateTimeOffset createdAtUtc) => new() { Id = Guid.NewGuid(), @@ -143,7 +166,15 @@ public static async Task CreateAsync() await connection.OpenAsync(); var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Development" }); builder.WebHost.UseTestServer(); + builder.Services.AddHttpContextAccessor(); builder.Services.AddDbContext(options => options.UseSqlite(connection)); + builder.Services.AddDbContext(options => options.UseSqlite(connection)); + builder.Services.AddScoped(_ => + { + var tenant = Substitute.For(); + tenant.IsHelpdeskAdmin.Returns(true); + return tenant; + }); builder.Services.AddSingleton(new TestAccessService()); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -164,6 +195,12 @@ public static async Task CreateAsync() await identity.Database.EnsureCreatedAsync(); identity.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner", Email = "owner@example.test", IsEnabled = true }); await identity.SaveChangesAsync(); + var domain = scope.ServiceProvider.GetRequiredService(); + await domain.GetService().CreateTablesAsync(); + domain.Organizations.AddRange( + new Organization { Id = "org-a", Name = "Organization A", IsEnabled = true }, + new Organization { Id = "org-b", Name = "Organization B", IsEnabled = true }); + await domain.SaveChangesAsync(); } return new Harness(application, connection); @@ -202,9 +239,17 @@ private sealed class TestAccessService : ICurrentUserAccessService private static readonly CurrentUserAccessProfile Profile = new( true, "owner", "owner@example.test", "org-a", null, null, false, new HashSet(StringComparer.OrdinalIgnoreCase), - new HashSet(["Incident.Read"], StringComparer.OrdinalIgnoreCase), - new HashSet(["org-a"], StringComparer.OrdinalIgnoreCase), - new HashSet(StringComparer.OrdinalIgnoreCase)); + new HashSet(["Incident.Read", "Incident.Write"], StringComparer.OrdinalIgnoreCase), + new HashSet(["org-a", "org-b"], StringComparer.OrdinalIgnoreCase), + new HashSet(StringComparer.OrdinalIgnoreCase)) + { + UsesScopedPermissions = true, + ScopedPermissionGrants = new HashSet + { + new("Incident.Read", "org-a"), + new("Incident.Write", "org-a") + } + }; public Task ResolveAsync(ClaimsPrincipal user, CancellationToken ct = default) => Task.FromResult(Profile); diff --git a/tests/Helpdesk.Tests/Infrastructure/CurrentUserAccessServiceTests.cs b/tests/Helpdesk.Tests/Infrastructure/CurrentUserAccessServiceTests.cs index 4f4f54cf..3c73572e 100644 --- a/tests/Helpdesk.Tests/Infrastructure/CurrentUserAccessServiceTests.cs +++ b/tests/Helpdesk.Tests/Infrastructure/CurrentUserAccessServiceTests.cs @@ -276,6 +276,70 @@ public async Task Disabled_local_identity_account_has_no_effective_access() Assert.Empty(access.AllowedOrganizationIds); } + [Fact] + public async Task Linked_oidc_identity_resolves_its_persisted_instance_administrator_account() + { + await using var db = CreateDb(); + await using var identityDb = CreateIdentityDb(); + db.CustomerAuthLinks.Add(new CustomerAuthLink + { + CustomerId = "customer-a", + LocalAccountId = "application-admin", + OidcIssuer = "https://id.example.com/application/o/rateldesk", + OidcSubject = "linked-admin", + InviteStatus = CustomerInviteStatus.Active + }); + identityDb.Users.Add(new ApplicationUser + { + Id = "application-admin", + UserName = "admin@example.test", + Email = "admin@example.test", + IsEnabled = true, + IsInstanceAdministrator = true + }); + await db.SaveChangesAsync(); + await identityDb.SaveChangesAsync(); + + var access = await new CurrentUserAccessService(db, identityDb) + .ResolveAsync(User("admin@example.test", "linked-admin")); + + Assert.True(access.IsHelpdeskAdmin); + } + + [Fact] + public async Task Instance_administrator_integration_credential_is_limited_to_its_requested_permission_tuple() + { + await using var db = CreateDb(); + await using var identityDb = CreateIdentityDb(); + db.Organizations.AddRange( + new Organization { Id = "org-a", Name = "Organization A" }, + new Organization { Id = "org-b", Name = "Organization B" }); + identityDb.Users.Add(new ApplicationUser + { + Id = "application-admin", + UserName = "admin@example.test", + IsEnabled = true, + IsInstanceAdministrator = true + }); + await db.SaveChangesAsync(); + await identityDb.SaveChangesAsync(); + var principal = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, "application-admin"), + new Claim("auth_mode", "integration"), + new Claim("integration_organization_id", "org-b"), + new Claim("integration_permission", HelpdeskPermissions.IncidentRead) + ], + "IntegrationCredential")); + + var access = await new CurrentUserAccessService(db, identityDb).ResolveAsync(principal); + + Assert.False(access.IsHelpdeskAdmin); + Assert.True(access.HasPermission(HelpdeskPermissions.IncidentRead, "org-b")); + Assert.False(access.HasPermission(HelpdeskPermissions.IncidentRead, "org-a")); + Assert.False(access.HasPermission(HelpdeskPermissions.IncidentWrite, "org-b")); + } + [Fact] public async Task Persisted_custom_role_permissions_are_scoped_to_the_assigned_tenant() { From cc1e18389ff4eaa5e43677295ed42e952b343df9 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 16:30:00 +0200 Subject: [PATCH 20/32] feat(account): add integration credential management --- .../Components/Layout/MainLayout.razor | 2 + .../Pages/IntegrationCredentials.razor | 342 ++++++++++++++++++ tests/ux/setup.spec.ts | 13 + 3 files changed, 357 insertions(+) create mode 100644 src/HelpDesk.NewWeb/Components/Pages/IntegrationCredentials.razor diff --git a/src/HelpDesk.NewWeb/Components/Layout/MainLayout.razor b/src/HelpDesk.NewWeb/Components/Layout/MainLayout.razor index ce189d73..30ee46fc 100644 --- a/src/HelpDesk.NewWeb/Components/Layout/MainLayout.razor +++ b/src/HelpDesk.NewWeb/Components/Layout/MainLayout.razor @@ -63,6 +63,7 @@ Profile Preferences + Integration credentials @if (SupportsLocalAccounts) { Change passphrase @@ -90,6 +91,7 @@ Profile Preferences + Integration credentials @if (SupportsLocalAccounts) { Change passphrase diff --git a/src/HelpDesk.NewWeb/Components/Pages/IntegrationCredentials.razor b/src/HelpDesk.NewWeb/Components/Pages/IntegrationCredentials.razor new file mode 100644 index 00000000..a5ae23dc --- /dev/null +++ b/src/HelpDesk.NewWeb/Components/Pages/IntegrationCredentials.razor @@ -0,0 +1,342 @@ +@page "/account/integration-credentials" +@attribute [Authorize] +@using Helpdesk.Shared.Auth +@using Helpdesk.Shared.DTOs.Organization +@using Helpdesk.Shared.Services +@inject IHttpClientFactory HttpClientFactory +@inject AuthenticationStateProvider AuthenticationState + +Integration credentials + + + + + + Integration credentials + Create credentials scoped to one organization and the permissions you choose. The secret is displayed once. + + + Create credential + + + @if (!string.IsNullOrWhiteSpace(error)) + { + @error + } + + @if (created is not null) + { + + Copy this credential now + It will not be shown again. Store it in an approved secret manager. + + I have saved it + + } + + @if (showCreate) + { + + Create credential + + + + + + + API / CLI + HTTP MCP + + + + + + + + @foreach (var organization in organizations) + { + @organization.Name + } + + + + + @foreach (var permission in AvailablePermissions) + { + @PermissionLabel(permission) + } + + + @if (string.Equals(draft.Purpose, "mcp", StringComparison.OrdinalIgnoreCase)) + { + + + + } + + @if (organizations.Count == 0) + { + No enabled organization is currently available for a credential. + } + + Cancel + @(saving ? "Creating…" : "Create and reveal") + + + } + + @if (loading) + { + + } + else if (credentials.Count == 0) + { + No integration credentials have been created for this account. + } + else + { + + + Name + Purpose + Scope + Expires + Last used + + + + + @credential.Name + @credential.Prefix + + @credential.Purpose.ToUpperInvariant() + + @OrganizationLabel(credential.OrganizationId) + @string.Join(", ", credential.Permissions) + + @credential.ExpiresAtUtc.LocalDateTime.ToString("g") + @(credential.LastUsedAtUtc?.LocalDateTime.ToString("g") ?? "Never") + + @if (credential.RevokedAtUtc is not null) + { + Revoked + } + else + { + Revoke + } + + + + } + + + +@code { + private HttpClient Api => HttpClientFactory.CreateClient("HelpdeskApi"); + private CurrentUserAccessProfile? access; + private List organizations = []; + private List credentials = []; + private CreateCredentialDraft draft = new(); + private CreatedCredential? created; + private bool loading = true; + private bool saving; + private bool showCreate; + private string? error; + + private IEnumerable AvailablePermissions + { + get + { + if (access?.IsHelpdeskAdmin == true) + { + return HelpdeskPermissions.AssignablePermissions; + } + + if (access is null) + { + return []; + } + + if (!access.UsesScopedPermissions && access.ScopedPermissionGrants.Count == 0) + { + return access.Permissions + .Where(permission => HelpdeskPermissions.AssignablePermissions.Contains(permission, StringComparer.OrdinalIgnoreCase)) + .Order(StringComparer.OrdinalIgnoreCase); + } + + return access.ScopedPermissionGrants + .Where(grant => string.Equals(grant.OrganizationId, draft.OrganizationId, StringComparison.OrdinalIgnoreCase)) + .Select(grant => grant.Permission) + .Where(permission => HelpdeskPermissions.AssignablePermissions.Contains(permission, StringComparer.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase); + } + } + + protected override async Task OnInitializedAsync() + { + access = CurrentUserAccessProfile.FromClaims((await AuthenticationState.GetAuthenticationStateAsync()).User); + await ReloadAsync(); + } + + private async Task ReloadAsync() + { + loading = true; + error = null; + try + { + var organizationTask = Api.GetFromJsonAsync>("api/v1/organizations"); + var credentialTask = Api.GetFromJsonAsync>("api/v1/integration-credentials/"); + await Task.WhenAll(organizationTask, credentialTask); + organizations = (organizationTask.Result ?? []) + .Where(organization => access?.IsHelpdeskAdmin == true || access?.AllowedOrganizationIds.Contains(organization.Id) == true) + .OrderBy(organization => organization.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + credentials = (credentialTask.Result ?? []) + .OrderByDescending(credential => credential.CreatedAtUtc) + .ThenByDescending(credential => credential.Id) + .ToList(); + } + catch (HttpRequestException) + { + error = "Credentials could not be loaded. Confirm that your current session is authorized and retry."; + } + finally + { + loading = false; + } + } + + private void StartCreate() + { + error = null; + created = null; + draft = new CreateCredentialDraft { OrganizationId = organizations.FirstOrDefault()?.Id ?? string.Empty }; + showCreate = true; + } + + private void CancelCreate() + { + showCreate = false; + draft = new(); + } + + private Task OnOrganizationChanged(string value) + { + draft.OrganizationId = value; + draft.Permissions.IntersectWith(AvailablePermissions); + return Task.CompletedTask; + } + + private Task OnPermissionsChanged(IEnumerable? values) + { + draft.Permissions = values?.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? []; + return Task.CompletedTask; + } + + private async Task CreateAsync() + { + error = null; + if (string.IsNullOrWhiteSpace(draft.Name) || string.IsNullOrWhiteSpace(draft.OrganizationId) || draft.Permissions.Count == 0) + { + error = "Enter a name, organization, and at least one permission."; + return; + } + + saving = true; + try + { + var request = new CreateCredentialRequest( + draft.Name.Trim(), draft.Purpose, draft.OrganizationId, draft.Permissions.Order(StringComparer.OrdinalIgnoreCase).ToArray(), draft.LifetimeDays) + { + McpResourceUri = string.Equals(draft.Purpose, "mcp", StringComparison.OrdinalIgnoreCase) + ? draft.McpResourceUri?.Trim() + : null + }; + using var response = await Api.PostAsJsonAsync("api/v1/integration-credentials/", request); + if (!response.IsSuccessStatusCode) + { + error = "The credential could not be created. Check its organization, permissions, purpose, resource URI, and lifetime."; + return; + } + + created = await response.Content.ReadFromJsonAsync(); + if (created is null) + { + error = "The credential was created, but its one-time secret could not be displayed. Revoke it and create a replacement."; + return; + } + + showCreate = false; + await ReloadAsync(); + } + catch (HttpRequestException) + { + error = "The credential service is unavailable. Please retry."; + } + finally + { + saving = false; + } + } + + private async Task RevokeAsync(CredentialMetadata credential) + { + error = null; + saving = true; + try + { + using var response = await Api.DeleteAsync($"api/v1/integration-credentials/{credential.Id:N}"); + if (!response.IsSuccessStatusCode) + { + error = "The credential could not be revoked. Refresh the page and retry."; + return; + } + + await ReloadAsync(); + } + catch (HttpRequestException) + { + error = "The credential service is unavailable. Please retry."; + } + finally + { + saving = false; + } + } + + private void ClearCreatedCredential() => created = null; + + private string OrganizationLabel(string? organizationId) => + organizations.FirstOrDefault(organization => string.Equals(organization.Id, organizationId, StringComparison.OrdinalIgnoreCase))?.Name + ?? organizationId + ?? "No organization"; + + private static string PermissionLabel(string permission) => permission.Replace('.', ' '); + + private sealed class CreateCredentialDraft + { + public string Name { get; set; } = string.Empty; + public string Purpose { get; set; } = "api"; + public string OrganizationId { get; set; } = string.Empty; + public HashSet Permissions { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public int? LifetimeDays { get; set; } = 30; + public string? McpResourceUri { get; set; } + } + + private sealed record CreateCredentialRequest(string Name, string Purpose, string OrganizationId, IReadOnlyList Permissions, int? LifetimeDays) + { + public string? McpResourceUri { get; init; } + } + + private sealed record CredentialMetadata(Guid Id, string Name, string Prefix, string Purpose, string? OrganizationId, IReadOnlyList Permissions, DateTimeOffset ExpiresAtUtc, DateTimeOffset CreatedAtUtc, DateTimeOffset? LastUsedAtUtc, DateTimeOffset? RevokedAtUtc) + { + public string? McpResourceUri { get; init; } + } + + private sealed record CreatedCredential(Guid Id, string Prefix, string Secret, string Purpose, string? OrganizationId, IReadOnlyList Permissions, DateTimeOffset ExpiresAtUtc) + { + public string? McpResourceUri { get; init; } + } +} diff --git a/tests/ux/setup.spec.ts b/tests/ux/setup.spec.ts index 3d00ad1a..f92faf0c 100644 --- a/tests/ux/setup.spec.ts +++ b/tests/ux/setup.spec.ts @@ -143,6 +143,19 @@ test('first-run setup initializes, survives restart, and supports isolated scope const locked = await page.request.post('/api/v1/setup/session', { data: { setupCode } }); expect(locked.status()).toBeGreaterThanOrEqual(400); + // Account security is available to the signed-in application identity, not only + // through a local-account-only administration page. + await page.goto('/account/integration-credentials'); + await expect(page.getByTestId('integration-credentials-page')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Integration credentials', exact: true })).toBeVisible(); + await expect(page.getByTestId('integration-credential-create')).toBeEnabled(); + await page.getByTestId('integration-credential-create').click(); + await expect(page.getByLabel('Name', { exact: true })).toBeVisible(); + await expect(page.getByLabel('Organization', { exact: true })).toBeVisible(); + await expect(page.getByLabel('Permissions', { exact: true })).toBeVisible(); + await expect(page.getByText('The secret is displayed once.')).toBeVisible(); + await expect(page.locator('#blazor-error-ui')).not.toBeVisible(); + const headers = { 'X-Requested-With': 'XMLHttpRequest' }; const organizations = await (await page.request.get('/api/v1/ticketing/organizations?module=incident')).json(); const organizationId = organizations[0].id; From d37d996a917bebb7b95216caa9ca5128dcfc4817 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 16:32:38 +0200 Subject: [PATCH 21/32] test(release): execute extracted archives --- .github/workflows/pull-request-validation.yml | 36 ++++++++ .github/workflows/release.yml | 42 ++++++++- tools/release/test-executable-archives.sh | 91 +++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100755 tools/release/test-executable-archives.sh diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 59e47564..5c8a9e44 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -202,6 +202,9 @@ jobs: (cd release-assets && sha256sum --check SHA256SUMS) test -s release-assets/release-manifest.json + - name: Execute extracted Linux x64 archives + run: tools/release/test-executable-archives.sh release-assets linux-x64 + - name: Test draft release asset upload semantics run: tools/release/test-publish-release-assets.sh @@ -216,6 +219,39 @@ jobs: release-assets/release-manifest.json retention-days: 14 + archive-runtime: + name: Release archive runtime (${{ matrix.rid }}) + needs: release-assets + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + rid: linux-x64 + - runner: ubuntu-24.04-arm + rid: linux-arm64 + - runner: windows-latest + rid: win-x64 + - runner: macos-13 + rid: osx-x64 + - runner: macos-14 + rid: osx-arm64 + steps: + - name: Check out archive validation script + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Download the validated release rehearsal payload + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-asset-rehearsal + path: release-assets + + - name: Extract and execute matching release archives + shell: bash + run: tools/release/test-executable-archives.sh release-assets "${{ matrix.rid }}" + compose: name: Compose startup validation runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a0a1a17..b00cedcd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,6 +118,9 @@ jobs: - name: Verify executable asset checksums run: (cd release-assets && sha256sum --check SHA256SUMS) + - name: Execute extracted Linux x64 archives + run: tools/release/test-executable-archives.sh release-assets linux-x64 + - name: Test draft release asset upload semantics run: tools/release/test-publish-release-assets.sh @@ -147,6 +150,43 @@ jobs: if: always() run: docker compose -f docker/docker-compose.yml down --volumes --remove-orphans + archive-runtime: + name: Release archive runtime (${{ matrix.rid }}) + needs: validation + runs-on: ${{ matrix.runner }} + permissions: + contents: read + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + rid: linux-x64 + - runner: ubuntu-24.04-arm + rid: linux-arm64 + - runner: windows-latest + rid: win-x64 + - runner: macos-13 + rid: osx-x64 + - runner: macos-14 + rid: osx-arm64 + steps: + - name: Check out release scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.ref }} + + - name: Download the validated release payload + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-assets + path: release-assets + + - name: Extract and execute matching release archives + shell: bash + run: tools/release/test-executable-archives.sh release-assets "${{ matrix.rid }}" + publish-web: name: Publish Web image needs: [metadata, validation] @@ -335,7 +375,7 @@ jobs: release: name: Create GitHub Release - needs: [metadata, validation, publish-web, publish-api, publish-mcp-http] + needs: [metadata, validation, archive-runtime, publish-web, publish-api, publish-mcp-http] runs-on: ubuntu-latest permissions: contents: write diff --git a/tools/release/test-executable-archives.sh b/tools/release/test-executable-archives.sh new file mode 100755 index 00000000..aff4507c --- /dev/null +++ b/tools/release/test-executable-archives.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Executes the CLI and stdio MCP apphosts from a generated release archive. +# Invoke this on the matching platform (or a deliberate emulator); it never +# contacts a RatelDesk API and creates only a disposable local MCP config. +if [[ $# -ne 2 ]]; then + echo "usage: $0 " >&2 + exit 64 +fi + +asset_directory="$1" +rid="$2" +[[ -d "$asset_directory" ]] || { echo "Asset directory does not exist: $asset_directory" >&2; exit 1; } + +case "$rid" in + linux-x64|linux-arm64|osx-x64|osx-arm64) extension=tar.gz ;; + win-x64) extension=zip ;; + *) echo "Unsupported RID: $rid" >&2; exit 64 ;; +esac + +cli_archive="$(find "$asset_directory" -maxdepth 1 -type f -name "rateldesk-cli-*-$rid.$extension" -print -quit)" +mcp_archive="$(find "$asset_directory" -maxdepth 1 -type f -name "rateldesk-mcp-stdio-*-$rid.$extension" -print -quit)" +[[ -n "$cli_archive" && -n "$mcp_archive" ]] || { echo "Missing executable archive(s) for $rid." >&2; exit 1; } + +temporary_directory="$(mktemp -d)" +trap 'rm -rf "$temporary_directory"' EXIT +python_bin="$(command -v python3 || command -v python)" + +extract_archive() { + local archive="$1" destination="$2" + mkdir -p "$destination" + if [[ "$archive" == *.zip ]]; then + unzip -q "$archive" -d "$destination" + else + tar -xzf "$archive" -C "$destination" + fi +} + +extract_archive "$cli_archive" "$temporary_directory/cli" +extract_archive "$mcp_archive" "$temporary_directory/mcp" + +cli_directory="$(find "$temporary_directory/cli" -mindepth 1 -maxdepth 1 -type d -print -quit)" +mcp_directory="$(find "$temporary_directory/mcp" -mindepth 1 -maxdepth 1 -type d -print -quit)" +[[ -n "$cli_directory" && -n "$mcp_directory" ]] || { echo "Archives must contain one package directory." >&2; exit 1; } + +suffix="" +[[ "$rid" == win-x64 ]] && suffix=.exe +cli="$cli_directory/rateldesk$suffix" +mcp="$mcp_directory/rateldesk-mcp$suffix" +[[ -f "$cli" && -f "$mcp" ]] || { echo "Archive apphost is missing for $rid." >&2; exit 1; } +[[ "$rid" == win-x64 || -x "$cli" ]] || { echo "CLI apphost is not executable for $rid." >&2; exit 1; } +[[ "$rid" == win-x64 || -x "$mcp" ]] || { echo "MCP apphost is not executable for $rid." >&2; exit 1; } + +"$cli" --version >"$temporary_directory/cli-version.txt" +"$cli" --help >"$temporary_directory/cli-help.txt" +"$mcp" --version >"$temporary_directory/mcp-version.txt" +"$mcp" --help >"$temporary_directory/mcp-help.txt" +grep -q '^rateldesk ' "$temporary_directory/cli-version.txt" +grep -q '^rateldesk-mcp ' "$temporary_directory/mcp-version.txt" +grep -q 'stdout is reserved for MCP protocol traffic' "$temporary_directory/mcp-help.txt" + +configuration="$temporary_directory/mcp-config.json" +cat > "$configuration" <<'EOF' +{ + "apiBaseUrl": "https://api.example.test", + "credentialMode": "integration", + "integrationCredential": "rdk_archive_probe" +} +EOF + +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"release-archive-probe","version":"1"}}}' \ + | RATELDESK_MCP_CONFIG="$configuration" \ + RATELDESK_MCP_INSTANCE=archivetest \ + RATELDESK_MCP_ARCHIVETEST_API_BASE_URL=https://api.example.test \ + "$mcp" >"$temporary_directory/mcp-stdout.jsonl" 2>"$temporary_directory/mcp-stderr.txt" + +"$python_bin" - "$temporary_directory/mcp-stdout.jsonl" <<'PY' +import json +import pathlib +import sys + +lines = [line for line in pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").splitlines() if line.strip()] +if len(lines) != 1: + raise SystemExit(f"Expected exactly one protocol response on stdout, found {len(lines)} line(s).") +payload = json.loads(lines[0]) +if payload.get("id") != 1 or "result" not in payload: + raise SystemExit("MCP initialize did not return a successful JSON-RPC response.") +PY + +echo "Verified extracted executable archives for $rid." From 4255f954e9f0cc0cf5fe5d8dffcf98e19181a5f1 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 16:40:04 +0200 Subject: [PATCH 22/32] fix(ci): stabilize release and credential checks --- tests/ux/setup.spec.ts | 2 +- tools/release/test-executable-archives.sh | 54 ++++++++++++++++++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/tests/ux/setup.spec.ts b/tests/ux/setup.spec.ts index f92faf0c..1b33998c 100644 --- a/tests/ux/setup.spec.ts +++ b/tests/ux/setup.spec.ts @@ -151,7 +151,7 @@ test('first-run setup initializes, survives restart, and supports isolated scope await expect(page.getByTestId('integration-credential-create')).toBeEnabled(); await page.getByTestId('integration-credential-create').click(); await expect(page.getByLabel('Name', { exact: true })).toBeVisible(); - await expect(page.getByLabel('Organization', { exact: true })).toBeVisible(); + await expect(page.getByRole('combobox', { name: 'Organization', exact: true })).toBeVisible(); await expect(page.getByLabel('Permissions', { exact: true })).toBeVisible(); await expect(page.getByText('The secret is displayed once.')).toBeVisible(); await expect(page.locator('#blazor-error-ui')).not.toBeVisible(); diff --git a/tools/release/test-executable-archives.sh b/tools/release/test-executable-archives.sh index aff4507c..27b1e2f0 100755 --- a/tools/release/test-executable-archives.sh +++ b/tools/release/test-executable-archives.sh @@ -69,11 +69,55 @@ cat > "$configuration" <<'EOF' } EOF -printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"release-archive-probe","version":"1"}}}' \ - | RATELDESK_MCP_CONFIG="$configuration" \ - RATELDESK_MCP_INSTANCE=archivetest \ - RATELDESK_MCP_ARCHIVETEST_API_BASE_URL=https://api.example.test \ - "$mcp" >"$temporary_directory/mcp-stdout.jsonl" 2>"$temporary_directory/mcp-stderr.txt" +"$python_bin" - "$mcp" "$configuration" "$temporary_directory/mcp-stdout.jsonl" "$temporary_directory/mcp-stderr.txt" <<'PY' +import os +import queue +import subprocess +import sys +import threading + +mcp, configuration, stdout_path, stderr_path = sys.argv[1:] +environment = os.environ.copy() +environment.update({ + "RATELDESK_MCP_CONFIG": configuration, + "RATELDESK_MCP_INSTANCE": "archivetest", + "RATELDESK_MCP_ARCHIVETEST_API_BASE_URL": "https://api.example.test", +}) +process = subprocess.Popen( + [mcp], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + env=environment, +) +response = queue.Queue() +threading.Thread(target=lambda: response.put(process.stdout.readline()), daemon=True).start() +process.stdin.write('{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"release-archive-probe","version":"1"}}}\n') +process.stdin.flush() +try: + first_line = response.get(timeout=10) +except queue.Empty: + process.kill() + process.wait() + stderr = process.stderr.read() + open(stderr_path, "w", encoding="utf-8").write(stderr) + raise SystemExit("The extracted MCP server did not respond to initialize within 10 seconds.") + +process.stdin.close() +try: + process.wait(timeout=10) +except subprocess.TimeoutExpired: + process.kill() + process.wait() +remaining_output = process.stdout.read() +stderr = process.stderr.read() +open(stdout_path, "w", encoding="utf-8").write(first_line + remaining_output) +open(stderr_path, "w", encoding="utf-8").write(stderr) +if process.returncode != 0: + raise SystemExit(f"The extracted MCP server exited with code {process.returncode}.") +PY "$python_bin" - "$temporary_directory/mcp-stdout.jsonl" <<'PY' import json From eafff6c6b8d79611bf1e78ce775d19ff4eaabc7b Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 16:56:54 +0200 Subject: [PATCH 23/32] test(openapi): verify Scalar taxonomy and proxy --- .../Endpoints/Activity/ActivityEndpoints.cs | 3 +- .../IntegrationCredentialEndpoints.cs | 2 +- .../Endpoints/Errors/ErrorLoggingEndpoints.cs | 3 +- .../Users/TenantAdministrationEndpoints.cs | 3 +- src/Helpdesk.API/Program.cs | 10 ++--- .../Api/OpenApiAndVersionEndpointsTests.cs | 41 ++++++++++++++++++- .../NewWeb/WebAuthRoutesTests.cs | 14 +++++++ tests/ux/setup.spec.ts | 15 +++++++ 8 files changed, 80 insertions(+), 11 deletions(-) diff --git a/src/Helpdesk.API/Endpoints/Activity/ActivityEndpoints.cs b/src/Helpdesk.API/Endpoints/Activity/ActivityEndpoints.cs index 6b09b7c7..c16a940d 100644 --- a/src/Helpdesk.API/Endpoints/Activity/ActivityEndpoints.cs +++ b/src/Helpdesk.API/Endpoints/Activity/ActivityEndpoints.cs @@ -55,6 +55,7 @@ public static void MapActivityEndpoints(this IEndpointRouteBuilder app) .RequireAuthorization() .WithName("GetIncidentActivity") .WithSummary("Incident activity log") - .WithDescription("Returns log entries for the specified incident."); + .WithDescription("Returns log entries for the specified incident.") + .WithTags("Timeline"); } } diff --git a/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs b/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs index 009f9ff9..99ee8f3c 100644 --- a/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs +++ b/src/Helpdesk.API/Endpoints/Authentication/IntegrationCredentialEndpoints.cs @@ -156,7 +156,7 @@ public static void MapIntegrationCredentialEndpoints(this IEndpointRouteBuilder await identityDb.SaveChangesAsync(ct); } return Results.NoContent(); - }).RequireAuthorization(SelfRevocationPolicy).WithSummary("Revoke the presenting integration credential"); + }).RequireAuthorization(SelfRevocationPolicy).WithTags("Integration Credentials").WithSummary("Revoke the presenting integration credential"); } private static string? CanonicalMcpResourceUri(string? value) diff --git a/src/Helpdesk.API/Endpoints/Errors/ErrorLoggingEndpoints.cs b/src/Helpdesk.API/Endpoints/Errors/ErrorLoggingEndpoints.cs index 0a7eeeb7..a588c675 100644 --- a/src/Helpdesk.API/Endpoints/Errors/ErrorLoggingEndpoints.cs +++ b/src/Helpdesk.API/Endpoints/Errors/ErrorLoggingEndpoints.cs @@ -29,6 +29,7 @@ public static void MapErrorLoggingEndpoints(this IEndpointRouteBuilder app) .RequireAuthorization("HelpdeskAdmin") .WithName("LogClientError") .WithSummary("Client error logging") - .WithDescription("Captures errors from the UI"); + .WithDescription("Captures errors from the UI") + .WithTags("System"); } } diff --git a/src/Helpdesk.API/Endpoints/Users/TenantAdministrationEndpoints.cs b/src/Helpdesk.API/Endpoints/Users/TenantAdministrationEndpoints.cs index 17c2ab22..274bbf39 100644 --- a/src/Helpdesk.API/Endpoints/Users/TenantAdministrationEndpoints.cs +++ b/src/Helpdesk.API/Endpoints/Users/TenantAdministrationEndpoints.cs @@ -56,7 +56,8 @@ public static void MapTenantAdministrationEndpoints(this IEndpointRouteBuilder a .ToArrayAsync(cancellationToken)); }) .RequireAuthorization() - .WithName("GetTenantAdministrationOrganizations"); + .WithName("GetTenantAdministrationOrganizations") + .WithTags("Tenant administration"); var settings = app.MapGroup("/api/v1/tenant-admin/organizations/{organizationId}/settings") .WithTags("Tenant settings") diff --git a/src/Helpdesk.API/Program.cs b/src/Helpdesk.API/Program.cs index 9e638e05..4305aa91 100644 --- a/src/Helpdesk.API/Program.cs +++ b/src/Helpdesk.API/Program.cs @@ -1147,8 +1147,8 @@ await OrchestrationCallbackEndpoints.PublishRejectedAsync( app.MapOpenApi().AllowAnonymous(); // Health checks -app.MapGet("/health/ready", () => Results.Ok(new { status = "ready" })); -app.MapGet("/health/live", () => Results.Ok(new { status = "alive" })); +app.MapGet("/health/ready", () => Results.Ok(new { status = "ready" })).WithTags("Health"); +app.MapGet("/health/live", () => Results.Ok(new { status = "alive" })).WithTags("Health"); if (app.Environment.IsDevelopment()) { IResult WriteDebugLog(ILoggerFactory loggerFactory) @@ -1263,7 +1263,7 @@ IResult WriteDebugLog(ILoggerFactory loggerFactory) authScheme = ctx.User.Identities.Select(i => i.AuthenticationType).ToArray(), name = ctx.User.Identity?.Name, roles = ctx.User.Claims.Where(c => c.Type == "roles" || c.Type == ClaimTypes.Role).Select(c => c.Value).ToArray() -}).RequireAuthorization(); +}).RequireAuthorization().WithTags("System"); #endif app.MapGet("/health/db", async ([FromServices] HelpdeskDbContext db, CancellationToken token) => { @@ -1336,7 +1336,7 @@ await logs.CreateAsync(new ActivityLog } return Results.Created($"/api/v1/incidents/{ticket.Id}", ticket); // 201 -}).RequireAuthorization("HelpdeskAdmin"); +}).RequireAuthorization("HelpdeskAdmin").WithTags("Email"); app.MapHub("/notification-hub") .RequireAuthorization("NotificationAccess"); @@ -1349,7 +1349,7 @@ await logs.CreateAsync(new ActivityLog var s = prot.Protect("ok"); var u = prot.Unprotect(s); return Results.Ok(new { protectedLength = s.Length, unprotected = u }); - }); + }).WithTags("System"); } if (app.Environment.IsDevelopment() && runStartupTasks) diff --git a/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs b/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs index 21c6ed55..40167293 100644 --- a/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs +++ b/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs @@ -19,8 +19,8 @@ public OpenApiAndVersionEndpointsTests(WebApplicationFactory factory) _factory = factory.WithWebHostBuilder(builder => { builder.UseIsolatedTestStorage(); - builder.UseSetting(WebHostDefaults.EnvironmentKey, "Development"); - builder.UseEnvironment("Development"); + builder.UseSetting(WebHostDefaults.EnvironmentKey, "Production"); + builder.UseEnvironment("Production"); builder.ConfigureAppConfiguration((_, cfg) => { cfg.AddInMemoryCollection(new Dictionary @@ -46,6 +46,10 @@ public async Task OpenApi_V1_Json_IsServed() Assert.Contains("\"openapi\"", content, StringComparison.OrdinalIgnoreCase); Assert.Contains("/api/v1/system/version", content, StringComparison.Ordinal); using var document = JsonDocument.Parse(content); + // The direct API document keeps Try It on its own root; the Web-hosted + // Scalar configuration supplies the distinct /api proxy server below. + var directServers = document.RootElement.GetProperty("servers"); + Assert.Equal(client.BaseAddress!.GetLeftPart(UriPartial.Authority) + "/", directServers[0].GetProperty("url").GetString()); Assert.Equal("RatelDesk API", document.RootElement.GetProperty("info").GetProperty("title").GetString()); Assert.False(string.IsNullOrWhiteSpace(document.RootElement.GetProperty("info").GetProperty("version").GetString())); Assert.True(document.RootElement.TryGetProperty("x-tagGroups", out var groups)); @@ -73,6 +77,39 @@ public async Task OpenApi_V1_Json_IsServed() Assert.Equal(["McpIntegrationCredential"], SecuritySchemes(document, "/api/v1/mcp/execution-token", "post")); } + [Fact] + public async Task OpenApi_V1_Json_classifies_every_documented_operation() + { + using var client = _factory.CreateClient(); + using var document = JsonDocument.Parse(await client.GetStringAsync("/openapi/v1.json")); + var knownTags = document.RootElement.GetProperty("tags") + .EnumerateArray() + .Select(tag => tag.GetProperty("name").GetString()) + .OfType() + .ToHashSet(StringComparer.Ordinal); + var violations = new List(); + var operationCount = 0; + + foreach (var path in document.RootElement.GetProperty("paths").EnumerateObject()) + { + foreach (var operation in path.Value.EnumerateObject().Where(item => item.Name is "delete" or "get" or "head" or "options" or "patch" or "post" or "put")) + { + operationCount++; + if (!operation.Value.TryGetProperty("tags", out var tags) || tags.GetArrayLength() == 0) + { + violations.Add($"{operation.Name.ToUpperInvariant()} {path.Name} is untagged"); + continue; + } + + foreach (var tag in tags.EnumerateArray().Select(item => item.GetString()).OfType().Where(tag => !knownTags.Contains(tag))) + violations.Add($"{operation.Name.ToUpperInvariant()} {path.Name} uses unknown tag '{tag}'"); + } + } + + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + Assert.Equal(343, operationCount); + } + private static string[] SecuritySchemes(JsonDocument document, string path, string method) { var paths = document.RootElement.GetProperty("paths"); diff --git a/tests/Helpdesk.Tests/NewWeb/WebAuthRoutesTests.cs b/tests/Helpdesk.Tests/NewWeb/WebAuthRoutesTests.cs index 785d8b97..6f7a0412 100644 --- a/tests/Helpdesk.Tests/NewWeb/WebAuthRoutesTests.cs +++ b/tests/Helpdesk.Tests/NewWeb/WebAuthRoutesTests.cs @@ -175,6 +175,20 @@ public async Task Api_Shorthand_Redirects_To_WebHosted_Scalar_Docs() Assert.Equal("/api/docs/", response.Headers.Location?.OriginalString); } + [Fact] + public async Task Scalar_reference_loads_the_document_and_uses_the_web_proxy_for_try_it() + { + using var factory = CreateFactory(); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/docs/"); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("/api/openapi/v1.json", html, StringComparison.Ordinal); + Assert.Contains("\"servers\":[{\"url\":\"/api\"}]", html, StringComparison.Ordinal); + } + [Fact] public async Task Login_Authentik_Challenges_Authentik_Oidc() { diff --git a/tests/ux/setup.spec.ts b/tests/ux/setup.spec.ts index 1b33998c..cdef8389 100644 --- a/tests/ux/setup.spec.ts +++ b/tests/ux/setup.spec.ts @@ -143,6 +143,21 @@ test('first-run setup initializes, survives restart, and supports isolated scope const locked = await page.request.post('/api/v1/setup/session', { data: { setupCode } }); expect(locked.status()).toBeGreaterThanOrEqual(400); + // The reference must render the API document through the public Web proxy; + // Scalar's configured /api server keeps Try It requests on that proxy rather + // than exposing the internal API address. + const openApi = await page.request.get('/api/openapi/v1.json'); + expect(openApi.ok(), await openApi.text()).toBe(true); + await page.goto('/api/docs/'); + await expect(page.locator('scalar-api-reference')).toBeVisible(); + await expect(page.getByText('RatelDesk API', { exact: true }).first()).toBeVisible(); + await expect(page.locator('script').filter({ hasText: '"servers":[{"url":"/api"}]' })).toHaveCount(1); + await testInfo.attach('scalar-reference', { + body: await page.screenshot({ path: testInfo.outputPath('scalar-reference.png'), fullPage: true }), + contentType: 'image/png' + }); + await expect(page.locator('#blazor-error-ui')).not.toBeVisible(); + // Account security is available to the signed-in application identity, not only // through a local-account-only administration page. await page.goto('/account/integration-credentials'); From 58f310b944295cf427797e80ab6495feb015cc33 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 17:00:16 +0200 Subject: [PATCH 24/32] fix(openapi): order generated API reference deterministically --- .../Documentation/RatelDeskOpenApiCatalog.cs | 18 +++++++++++++++++- .../Api/OpenApiAndVersionEndpointsTests.cs | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/Helpdesk.API/Documentation/RatelDeskOpenApiCatalog.cs b/src/Helpdesk.API/Documentation/RatelDeskOpenApiCatalog.cs index ba35b20a..6545c5bf 100644 --- a/src/Helpdesk.API/Documentation/RatelDeskOpenApiCatalog.cs +++ b/src/Helpdesk.API/Documentation/RatelDeskOpenApiCatalog.cs @@ -57,7 +57,23 @@ public static Task TransformDocumentAsync(OpenApiDocument document, Cancellation { document.Info.Title = "RatelDesk API"; document.Info.Description = "Use the Web-hosted reference at `/api/docs`. The API base URL is the server selected in Scalar. Local sign-in uses a browser cookie and CSRF protection; CLI and MCP use configured integration credentials. Organizations are application tenants; Customers are contacts. Pagination, filters, and errors are documented per operation."; - document.Tags = Tags.Select(tag => new OpenApiTag { Name = tag.Name, Description = tag.Description }).ToHashSet(); + // OpenApiDocument uses set and dictionary collections. Rebuild each collection in + // a defined order so the generated reference is stable across endpoint discovery + // order, rather than merely stable by the current collection implementation. + document.Tags = new SortedSet( + Tags.Select(tag => new OpenApiTag { Name = tag.Name, Description = tag.Description }), + Comparer.Create((left, right) => StringComparer.Ordinal.Compare(left.Name, right.Name))); + var paths = document.Paths.OrderBy(path => path.Key, StringComparer.Ordinal).ToArray(); + document.Paths.Clear(); + foreach (var path in paths) + { + var operations = path.Value.Operations.OrderBy(operation => operation.Key.ToString(), StringComparer.Ordinal).ToArray(); + path.Value.Operations.Clear(); + foreach (var operation in operations) + path.Value.Operations.Add(operation.Key, operation.Value); + + document.Paths.Add(path.Key, path.Value); + } document.Extensions ??= new Dictionary(); document.Extensions["x-tagGroups"] = new JsonNodeExtension(new JsonArray(Tags.GroupBy(tag => tag.Group).Select(group => (JsonNode)new JsonObject { diff --git a/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs b/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs index 40167293..7277cfd6 100644 --- a/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs +++ b/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs @@ -108,6 +108,23 @@ public async Task OpenApi_V1_Json_classifies_every_documented_operation() Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); Assert.Equal(343, operationCount); + + var pathOrder = document.RootElement.GetProperty("paths").EnumerateObject().Select(path => path.Name).ToArray(); + Assert.Equal(pathOrder.Order(StringComparer.Ordinal), pathOrder); + foreach (var path in document.RootElement.GetProperty("paths").EnumerateObject()) + { + var methodOrder = path.Value.EnumerateObject() + .Where(item => item.Name is "delete" or "get" or "head" or "options" or "patch" or "post" or "put") + .Select(item => item.Name) + .ToArray(); + Assert.Equal(methodOrder.Order(StringComparer.Ordinal), methodOrder); + } + + var tagOrder = document.RootElement.GetProperty("tags").EnumerateArray() + .Select(tag => tag.GetProperty("name").GetString()) + .OfType() + .ToArray(); + Assert.Equal(tagOrder.Order(StringComparer.Ordinal), tagOrder); } private static string[] SecuritySchemes(JsonDocument document, string path, string method) From eb725b12e7e836fa6571b75fb491481e1e5af8c8 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 17:05:05 +0200 Subject: [PATCH 25/32] test(credentials): enforce interactive sibling revocation boundary --- .../Api/IntegrationCredentialSqliteTests.cs | 91 +++++++++++++++++-- 1 file changed, 82 insertions(+), 9 deletions(-) diff --git a/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs b/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs index ce746264..9ddf7733 100644 --- a/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs +++ b/tests/Helpdesk.Tests/Api/IntegrationCredentialSqliteTests.cs @@ -139,6 +139,58 @@ public async Task Create_endpoint_rejects_a_permission_from_another_organization .IntegrationCredentials.ToListAsync()); } + [Theory] + [InlineData("integration")] + [InlineData("mcp")] + [InlineData("gateway")] + public async Task Non_interactive_credentials_cannot_revoke_a_same_owner_sibling(string authenticationMode) + { + await using var harness = await Harness.CreateAsync(); + var first = Credential("owner", "first", DateTimeOffset.UtcNow.AddMinutes(-1)); + var sibling = Credential("owner", "sibling", DateTimeOffset.UtcNow); + await harness.AddCredentialsAsync(first, sibling); + + using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/integration-credentials/{sibling.Id:N}"); + request.Headers.Add("X-Test-Auth-Mode", authenticationMode); + using var response = await harness.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.False(await harness.IsRevokedAsync(first.Id)); + Assert.False(await harness.IsRevokedAsync(sibling.Id)); + } + + [Fact] + public async Task Anonymous_and_wrong_owner_requests_cannot_revoke_a_credential() + { + await using var harness = await Harness.CreateAsync(); + var credential = Credential("owner", "owner credential", DateTimeOffset.UtcNow); + await harness.AddCredentialsAsync(credential); + + using var anonymous = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/integration-credentials/{credential.Id:N}"); + anonymous.Headers.Add("X-Test-Auth-Mode", "anonymous"); + using var anonymousResponse = await harness.Client.SendAsync(anonymous); + Assert.Equal(HttpStatusCode.Unauthorized, anonymousResponse.StatusCode); + + using var wrongOwner = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/integration-credentials/{credential.Id:N}"); + wrongOwner.Headers.Add("X-Test-User", "other"); + using var wrongOwnerResponse = await harness.Client.SendAsync(wrongOwner); + Assert.Equal(HttpStatusCode.NotFound, wrongOwnerResponse.StatusCode); + Assert.False(await harness.IsRevokedAsync(credential.Id)); + } + + [Fact] + public async Task Interactive_owner_can_revoke_its_own_credential() + { + await using var harness = await Harness.CreateAsync(); + var credential = Credential("owner", "owner credential", DateTimeOffset.UtcNow); + await harness.AddCredentialsAsync(credential); + + using var response = await harness.Client.DeleteAsync($"/api/v1/integration-credentials/{credential.Id:N}"); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.True(await harness.IsRevokedAsync(credential.Id)); + } + private static IntegrationCredential Credential(string ownerId, string name, DateTimeOffset createdAtUtc) => new() { Id = Guid.NewGuid(), @@ -176,7 +228,7 @@ public static async Task CreateAsync() return tenant; }); builder.Services.AddSingleton(new TestAccessService()); - builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddAuthentication("Test").AddScheme("Test", _ => { }); builder.Services.AddAuthorization(options => options.AddPolicy(IntegrationCredentialEndpoints.CredentialManagementPolicy, policy => @@ -193,7 +245,9 @@ public static async Task CreateAsync() { var identity = scope.ServiceProvider.GetRequiredService(); await identity.Database.EnsureCreatedAsync(); - identity.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner", Email = "owner@example.test", IsEnabled = true }); + identity.Users.AddRange( + new ApplicationUser { Id = "owner", UserName = "owner", Email = "owner@example.test", IsEnabled = true }, + new ApplicationUser { Id = "other", UserName = "other", Email = "other@example.test", IsEnabled = true }); await identity.SaveChangesAsync(); var domain = scope.ServiceProvider.GetRequiredService(); await domain.GetService().CreateTablesAsync(); @@ -206,6 +260,24 @@ public static async Task CreateAsync() return new Harness(application, connection); } + public async Task AddCredentialsAsync(params IntegrationCredential[] credentials) + { + await using var scope = application.Services.CreateAsyncScope(); + var identity = scope.ServiceProvider.GetRequiredService(); + identity.IntegrationCredentials.AddRange(credentials); + await identity.SaveChangesAsync(); + } + + public async Task IsRevokedAsync(Guid credentialId) + { + await using var scope = application.Services.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService() + .IntegrationCredentials + .Where(credential => credential.Id == credentialId) + .Select(credential => credential.RevokedAtUtc != null) + .SingleAsync(); + } + public async ValueTask DisposeAsync() { await application.DisposeAsync(); @@ -221,19 +293,20 @@ private sealed class TestAuthenticationHandler( { protected override Task HandleAuthenticateAsync() { + var authenticationMode = Request.Headers["X-Test-Auth-Mode"].ToString(); + if (string.Equals(authenticationMode, "anonymous", StringComparison.OrdinalIgnoreCase)) + return Task.FromResult(AuthenticateResult.NoResult()); + var identity = new ClaimsIdentity( - [new Claim(ClaimTypes.NameIdentifier, "owner"), new Claim("auth_mode", "local")], + [ + new Claim(ClaimTypes.NameIdentifier, Request.Headers["X-Test-User"].FirstOrDefault() ?? "owner"), + new Claim("auth_mode", string.IsNullOrWhiteSpace(authenticationMode) ? "local" : authenticationMode) + ], Scheme.Name); return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name))); } } - private sealed class StaticOwnerResolver : IIntegrationCredentialOwnerResolver - { - public Task ResolveAsync(ClaimsPrincipal principal, CancellationToken cancellationToken = default) - => Task.FromResult(new IntegrationCredentialOwner("owner")); - } - private sealed class TestAccessService : ICurrentUserAccessService { private static readonly CurrentUserAccessProfile Profile = new( From 4ee209e29dbfe9ca7ad224d350ffb228d28d75aa Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 17:11:12 +0200 Subject: [PATCH 26/32] test(ux): assert rendered Scalar reference --- tests/ux/setup.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/ux/setup.spec.ts b/tests/ux/setup.spec.ts index cdef8389..103cbee9 100644 --- a/tests/ux/setup.spec.ts +++ b/tests/ux/setup.spec.ts @@ -149,7 +149,10 @@ test('first-run setup initializes, survives restart, and supports isolated scope const openApi = await page.request.get('/api/openapi/v1.json'); expect(openApi.ok(), await openApi.text()).toBe(true); await page.goto('/api/docs/'); - await expect(page.locator('scalar-api-reference')).toBeVisible(); + // Scalar replaces its bootstrap custom element once it renders. Assert the + // rendered reference UI instead of the transient bootstrap element. + await expect(page.getByRole('complementary', { name: 'Sidebar for RatelDesk API' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Introduction', exact: true })).toBeVisible(); await expect(page.getByText('RatelDesk API', { exact: true }).first()).toBeVisible(); await expect(page.locator('script').filter({ hasText: '"servers":[{"url":"/api"}]' })).toHaveCount(1); await testInfo.attach('scalar-reference', { From 219310f90a10bff3019146a022f13c5d8ba0b963 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 17:15:41 +0200 Subject: [PATCH 27/32] test(ux): assert proxied OpenAPI server directly --- tests/ux/setup.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ux/setup.spec.ts b/tests/ux/setup.spec.ts index 103cbee9..5cf75e55 100644 --- a/tests/ux/setup.spec.ts +++ b/tests/ux/setup.spec.ts @@ -148,13 +148,14 @@ test('first-run setup initializes, survives restart, and supports isolated scope // than exposing the internal API address. const openApi = await page.request.get('/api/openapi/v1.json'); expect(openApi.ok(), await openApi.text()).toBe(true); + const openApiDocument = await openApi.json(); + expect(openApiDocument.servers).toEqual([{ url: '/api' }]); await page.goto('/api/docs/'); // Scalar replaces its bootstrap custom element once it renders. Assert the // rendered reference UI instead of the transient bootstrap element. await expect(page.getByRole('complementary', { name: 'Sidebar for RatelDesk API' })).toBeVisible(); await expect(page.getByRole('link', { name: 'Introduction', exact: true })).toBeVisible(); await expect(page.getByText('RatelDesk API', { exact: true }).first()).toBeVisible(); - await expect(page.locator('script').filter({ hasText: '"servers":[{"url":"/api"}]' })).toHaveCount(1); await testInfo.attach('scalar-reference', { body: await page.screenshot({ path: testInfo.outputPath('scalar-reference.png'), fullPage: true }), contentType: 'image/png' From 807a09852635c5f9ac445d107f7716d2d6e0f650 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 17:18:41 +0200 Subject: [PATCH 28/32] test(openapi): align release inventory baseline --- tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs b/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs index 7277cfd6..3c8b54c6 100644 --- a/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs +++ b/tests/Helpdesk.Tests/Api/OpenApiAndVersionEndpointsTests.cs @@ -107,7 +107,9 @@ public async Task OpenApi_V1_Json_classifies_every_documented_operation() } Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); - Assert.Equal(343, operationCount); + // The Release-mode inventory has 342 operations. Keep this explicit so + // additions or omissions require a reviewed taxonomy update. + Assert.Equal(342, operationCount); var pathOrder = document.RootElement.GetProperty("paths").EnumerateObject().Select(path => path.Name).ToArray(); Assert.Equal(pathOrder.Order(StringComparer.Ordinal), pathOrder); From a51908306bf404ca63d4667c2e7d137e5e2a648c Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 17:23:10 +0200 Subject: [PATCH 29/32] test(ux): reuse proxied OpenAPI payload --- tests/ux/setup.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/ux/setup.spec.ts b/tests/ux/setup.spec.ts index 5cf75e55..0b362ef1 100644 --- a/tests/ux/setup.spec.ts +++ b/tests/ux/setup.spec.ts @@ -147,8 +147,9 @@ test('first-run setup initializes, survives restart, and supports isolated scope // Scalar's configured /api server keeps Try It requests on that proxy rather // than exposing the internal API address. const openApi = await page.request.get('/api/openapi/v1.json'); - expect(openApi.ok(), await openApi.text()).toBe(true); - const openApiDocument = await openApi.json(); + const openApiContent = await openApi.text(); + expect(openApi.ok(), openApiContent).toBe(true); + const openApiDocument = JSON.parse(openApiContent); expect(openApiDocument.servers).toEqual([{ url: '/api' }]); await page.goto('/api/docs/'); // Scalar replaces its bootstrap custom element once it renders. Assert the From cfe3468cb2c6af557b0b15d9cda271b6421d432b Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 17:28:05 +0200 Subject: [PATCH 30/32] test(ux): separate proxy document and server checks --- tests/ux/setup.spec.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/ux/setup.spec.ts b/tests/ux/setup.spec.ts index 0b362ef1..5bf7520d 100644 --- a/tests/ux/setup.spec.ts +++ b/tests/ux/setup.spec.ts @@ -143,14 +143,12 @@ test('first-run setup initializes, survives restart, and supports isolated scope const locked = await page.request.post('/api/v1/setup/session', { data: { setupCode } }); expect(locked.status()).toBeGreaterThanOrEqual(400); - // The reference must render the API document through the public Web proxy; - // Scalar's configured /api server keeps Try It requests on that proxy rather - // than exposing the internal API address. + // The browser must load the reference document through the public Web proxy. + // The Web-host route test separately verifies Scalar's /api Try It server. const openApi = await page.request.get('/api/openapi/v1.json'); const openApiContent = await openApi.text(); expect(openApi.ok(), openApiContent).toBe(true); - const openApiDocument = JSON.parse(openApiContent); - expect(openApiDocument.servers).toEqual([{ url: '/api' }]); + expect(new URL(openApi.url()).pathname).toBe('/api/openapi/v1.json'); await page.goto('/api/docs/'); // Scalar replaces its bootstrap custom element once it renders. Assert the // rendered reference UI instead of the transient bootstrap element. From 1430eb1de2480458940b5cab1ad3d995bd94bf12 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 19:40:02 +0200 Subject: [PATCH 31/32] fix(release): retire macOS executable archives --- .github/workflows/pull-request-validation.yml | 4 ---- .github/workflows/release.yml | 4 ---- README.md | 2 +- tools/release/package-assets.sh | 4 ++-- tools/release/prepare-release-manifest.sh | 6 +++--- tools/release/publish-release-assets.sh | 2 +- tools/release/test-executable-archives.sh | 2 +- tools/release/test-publish-release-assets.sh | 9 +++++---- 8 files changed, 13 insertions(+), 20 deletions(-) diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 5c8a9e44..ca7d498b 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -234,10 +234,6 @@ jobs: rid: linux-arm64 - runner: windows-latest rid: win-x64 - - runner: macos-13 - rid: osx-x64 - - runner: macos-14 - rid: osx-arm64 steps: - name: Check out archive validation script uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b00cedcd..529d2117 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -167,10 +167,6 @@ jobs: rid: linux-arm64 - runner: windows-latest rid: win-x64 - - runner: macos-13 - rid: osx-x64 - - runner: macos-14 - rid: osx-arm64 steps: - name: Check out release scripts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/README.md b/README.md index b81470b1..e2da3a56 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ The Web-hosted [RatelDesk API reference](/api/docs) groups the public API by pro For automation, sign in normally, complete configured MFA, then create a bounded API integration credential through `POST /api/v1/integration-credentials`. The secret is returned only by the create response. Store it in a protected configuration file or secret mount. Its effective access is always the intersection of the account's current authorization, the credential's selected permissions, and its organization scope; revocation and account disablement take effect on later requests. -GitHub Releases contain self-contained `rateldesk` CLI and `rateldesk-mcp` stdio MCP archives for Linux x64/arm64, Windows x64, and macOS x64/arm64. The Linux archives target glibc distributions, not Alpine/musl. Both executables support offline `--help` and `--version` before loading credentials. +GitHub Releases contain self-contained `rateldesk` CLI and `rateldesk-mcp` stdio MCP archives for Linux x64/arm64 and Windows x64. The Linux archives target glibc distributions, not Alpine/musl. Both executables support offline `--help` and `--version` before loading credentials. HTTP MCP is optional and never joins the base Web/API stack. The source and release overlays are documented in [the HTTP MCP example](docker/examples/mcp-http/README.md). The existing Authentik HTTP MCP mode remains a separately configured external identity integration; it is not a fallback for local credentials. diff --git a/tools/release/package-assets.sh b/tools/release/package-assets.sh index faa9739e..f055603b 100755 --- a/tools/release/package-assets.sh +++ b/tools/release/package-assets.sh @@ -17,7 +17,7 @@ output_directory="$(cd "$output_directory" && pwd)" staging_directory="$output_directory/staging" mkdir -p "$staging_directory" -declare -a rids=(linux-x64 linux-arm64 win-x64 osx-x64 osx-arm64) +declare -a rids=(linux-x64 linux-arm64 win-x64) package_tool() { local project="$1" tool="$2" archive_prefix="$3" rid="$4" @@ -66,7 +66,7 @@ for line in (directory / "SHA256SUMS").read_text().splitlines(): hashes[name.strip()] = digest (directory / "release-manifest.json").write_text(json.dumps({ "version": sys.argv[2], "sourceRevision": sys.argv[3], - "supportedRids": ["linux-x64", "linux-arm64", "win-x64", "osx-x64", "osx-arm64"], + "supportedRids": ["linux-x64", "linux-arm64", "win-x64"], "assets": hashes, "containers": [ f"ghcr.io/bostontechnologies/rateldesk-web:{sys.argv[2]}", diff --git a/tools/release/prepare-release-manifest.sh b/tools/release/prepare-release-manifest.sh index 0c33b77c..a6d52c1e 100755 --- a/tools/release/prepare-release-manifest.sh +++ b/tools/release/prepare-release-manifest.sh @@ -41,8 +41,8 @@ for line in (directory / "SHA256SUMS").read_text(encoding="utf-8").splitlines(): assets[name] = digest expected_archives = { - *(f"rateldesk-cli-{version}-{rid}.{'zip' if rid == 'win-x64' else 'tar.gz'}" for rid in ("linux-x64", "linux-arm64", "win-x64", "osx-x64", "osx-arm64")), - *(f"rateldesk-mcp-stdio-{version}-{rid}.{'zip' if rid == 'win-x64' else 'tar.gz'}" for rid in ("linux-x64", "linux-arm64", "win-x64", "osx-x64", "osx-arm64")), + *(f"rateldesk-cli-{version}-{rid}.{'zip' if rid == 'win-x64' else 'tar.gz'}" for rid in ("linux-x64", "linux-arm64", "win-x64")), + *(f"rateldesk-mcp-stdio-{version}-{rid}.{'zip' if rid == 'win-x64' else 'tar.gz'}" for rid in ("linux-x64", "linux-arm64", "win-x64")), f"rateldesk-deployment-{version}.tar.gz", } if set(assets) != expected_archives: @@ -54,7 +54,7 @@ manifest = { "version": version, "tag": tag, "sourceRevision": revision, - "supportedRids": ["linux-x64", "linux-arm64", "win-x64", "osx-x64", "osx-arm64"], + "supportedRids": ["linux-x64", "linux-arm64", "win-x64"], "assets": dict(sorted(assets.items())), "containers": [ {"image": f"ghcr.io/bostontechnologies/rateldesk-web:{version}", "digest": web}, diff --git a/tools/release/publish-release-assets.sh b/tools/release/publish-release-assets.sh index c2c73349..a0831d86 100755 --- a/tools/release/publish-release-assets.sh +++ b/tools/release/publish-release-assets.sh @@ -19,7 +19,7 @@ if [[ ! -d "$asset_directory" ]]; then exit 1 fi -declare -a rids=(linux-x64 linux-arm64 win-x64 osx-x64 osx-arm64) +declare -a rids=(linux-x64 linux-arm64 win-x64) declare -a assets=() for rid in "${rids[@]}"; do suffix=tar.gz diff --git a/tools/release/test-executable-archives.sh b/tools/release/test-executable-archives.sh index 27b1e2f0..5002b2a0 100755 --- a/tools/release/test-executable-archives.sh +++ b/tools/release/test-executable-archives.sh @@ -14,7 +14,7 @@ rid="$2" [[ -d "$asset_directory" ]] || { echo "Asset directory does not exist: $asset_directory" >&2; exit 1; } case "$rid" in - linux-x64|linux-arm64|osx-x64|osx-arm64) extension=tar.gz ;; + linux-x64|linux-arm64) extension=tar.gz ;; win-x64) extension=zip ;; *) echo "Unsupported RID: $rid" >&2; exit 64 ;; esac diff --git a/tools/release/test-publish-release-assets.sh b/tools/release/test-publish-release-assets.sh index dc6085b1..22a4ec5d 100755 --- a/tools/release/test-publish-release-assets.sh +++ b/tools/release/test-publish-release-assets.sh @@ -10,7 +10,7 @@ mkdir -p "$assets" "$fake_gh_root/assets" version="0.0.0-test" tag="v$version" -declare -a rids=(linux-x64 linux-arm64 win-x64 osx-x64 osx-arm64) +declare -a rids=(linux-x64 linux-arm64 win-x64) declare -a archives=() for rid in "${rids[@]}"; do suffix=tar.gz @@ -18,6 +18,7 @@ for rid in "${rids[@]}"; do archives+=("rateldesk-cli-${version}-${rid}.${suffix}" "rateldesk-mcp-stdio-${version}-${rid}.${suffix}") done archives+=("rateldesk-deployment-${version}.tar.gz") +expected_asset_count=$((${#archives[@]} + 3)) # SHA256SUMS and the manifest plus its detached checksum for asset in "${archives[@]}"; do printf '%s\n' "$asset" > "$assets/$asset"; done (cd "$assets" && printf '%s\n' "${archives[@]}" | sort | xargs sha256sum > SHA256SUMS) "$repository_root/tools/release/prepare-release-manifest.sh" "$version" deadbeef "$assets" sha256:web sha256:api sha256:mcp "$tag" @@ -56,7 +57,7 @@ EOF chmod 700 "$temporary_directory/gh" RELEASE_ASSET_DRY_RUN=true "$repository_root/tools/release/publish-release-assets.sh" "$tag" "$version" "$assets" > "$temporary_directory/dry-run" -test "$(wc -l < "$temporary_directory/dry-run")" = 14 +test "$(wc -l < "$temporary_directory/dry-run")" = "$expected_asset_count" # Missing assets are rejected before any release command is run. mv "$assets/${archives[0]}" "$temporary_directory/missing-asset" @@ -68,7 +69,7 @@ mv "$temporary_directory/missing-asset" "$assets/${archives[0]}" # The first run creates a draft and uploads the complete payload. FAKE_GH_ROOT="$fake_gh_root" GH_BIN="$temporary_directory/gh" "$repository_root/tools/release/publish-release-assets.sh" "$tag" "$version" "$assets" -test "$(find "$fake_gh_root/assets" -maxdepth 1 -type f | wc -l)" = 14 +test "$(find "$fake_gh_root/assets" -maxdepth 1 -type f | wc -l)" = "$expected_asset_count" # A complete rerun reuses verified assets, while differing content is rejected. FAKE_GH_ROOT="$fake_gh_root" GH_BIN="$temporary_directory/gh" "$repository_root/tools/release/publish-release-assets.sh" "$tag" "$version" "$assets" @@ -84,7 +85,7 @@ if FAKE_GH_ROOT="$partial_root" FAKE_GH_FAIL_UPLOAD=true GH_BIN="$temporary_dire exit 1 fi FAKE_GH_ROOT="$partial_root" GH_BIN="$temporary_directory/gh" "$repository_root/tools/release/publish-release-assets.sh" "$tag" "$version" "$assets" -test "$(find "$partial_root/assets" -maxdepth 1 -type f | wc -l)" = 14 +test "$(find "$partial_root/assets" -maxdepth 1 -type f | wc -l)" = "$expected_asset_count" printf '%s\n' changed > "$assets/${archives[0]}" (cd "$assets" && printf '%s\n' "${archives[@]}" | sort | xargs sha256sum > SHA256SUMS) From 6449cdb62eaf7301229db5973d37789daa4a1499 Mon Sep 17 00:00:00 2001 From: Proxicon Date: Thu, 17 Sep 2026 19:42:52 +0200 Subject: [PATCH 32/32] fix(ci): align release rehearsal with supported archives --- .github/workflows/pull-request-validation.yml | 5 +++-- .github/workflows/release.yml | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index ca7d498b..d5707bd8 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -195,9 +195,9 @@ jobs: - name: Verify asset manifest and checksums run: | - test "$(find release-assets -maxdepth 1 -name 'rateldesk-cli-*.tar.gz' | wc -l)" = 4 + test "$(find release-assets -maxdepth 1 -name 'rateldesk-cli-*.tar.gz' | wc -l)" = 2 test "$(find release-assets -maxdepth 1 -name 'rateldesk-cli-*.zip' | wc -l)" = 1 - test "$(find release-assets -maxdepth 1 -name 'rateldesk-mcp-stdio-*.tar.gz' | wc -l)" = 4 + test "$(find release-assets -maxdepth 1 -name 'rateldesk-mcp-stdio-*.tar.gz' | wc -l)" = 2 test "$(find release-assets -maxdepth 1 -name 'rateldesk-mcp-stdio-*.zip' | wc -l)" = 1 (cd release-assets && sha256sum --check SHA256SUMS) test -s release-assets/release-manifest.json @@ -217,6 +217,7 @@ jobs: release-assets/*.zip release-assets/SHA256SUMS release-assets/release-manifest.json + release-assets/release-manifest.json.sha256 retention-days: 14 archive-runtime: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 529d2117..de706698 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -133,6 +133,7 @@ jobs: release-assets/*.zip release-assets/SHA256SUMS release-assets/release-manifest.json + release-assets/release-manifest.json.sha256 if-no-files-found: error - name: Start and smoke-test source Compose stack