diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e1eca34032..f116b2259d 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -191,7 +191,7 @@ This prevents nesting levels from getting deeper then they need to be. - Some terminology to help understand the architecture: - An instance can be thought of as a separate server. It has a separate directory, repository, set of byond installations, etc... The only thing shared amongst instances is API surface, users, global configuration, the active tgstation-server version, and the host machine. - - API refers to the HTTP API unless otherwise specified. + - API refers to the REST API unless otherwise specified. - The entirety of server functionality resides in the host (Tgstation.Server.Host) project. - A Component is a service running in tgstation-server to help with instance functionality. These can only be communicated with via the HTTP or DM APIs. - There is a difference between Watchdog and Host Watchdog. The former monitors DreamDaemon uptime, the latter handles updating tgstation-server. @@ -247,7 +247,7 @@ Warning: You may need to temporarily set valid MySql credentials in [MySqlDesign OAuth providers are hardcoded but it is fairly easy to add new ones. The flow doesn't need to be strict OAuth either (r.e. /tg/ forums). Follow the following steps: -1. Add the name to the [Tgstation.Server.Api.Models.OAuthProviders](../src/Tgstation.Server.Api/Models/OAuthProviders.cs) enum (Also necessitates a minor HTTP API version bump). +1. Add the name to the [Tgstation.Server.Api.Models.OAuthProviders](../src/Tgstation.Server.Api/Models/OAuthProviders.cs) enum (Also necessitates a minor API version bump to the HTTP APIs (REST/GraphQL)). 1. Create an implementation of [IOAuthValidator](../src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs). - Most providers can simply override the [GenericOAuthValidator](../src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs). 1. Construct the implementation in the [OAuthProviders](../src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs) class. @@ -275,7 +275,8 @@ Major changes should be committed to the `VX` branch created when the time for a We have several subcomponent APIs we ship with the core server that have their own versions. -- HTTP API +- REST API +- GraphQL API - DreamMaker API - Interop API - Configuration File @@ -308,7 +309,7 @@ Word commit names descriptively. Only submit work through pull requests (With th At the time of this writing, the repository is configured to automate much of the deployment/release process. -When the new API, client, or DMAPI is ready to be released, update the `Version.props` file appropriately and merge the pull request with the text `[APIDeploy]`, `[NuGetDeploy]`, or `[DMDeploy]` respectively in the commit message (or all three!). The release will be published automatically. +When the new API, client, or DMAPI is ready to be released, update the `Version.props` file appropriately and merge the pull request with the text `[RESTDeploy]`, `[GQLDeploy]`, `[NugetDeploy]`, or `[DMDeploy]` respectively in the commit message (or all three!). The release will be published automatically. That step should be taken for the latest API and client before releasing the core version that uses them if applicable. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a71922c1df..518a6e5b61 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -16,7 +16,7 @@ They will be amalgamated together in the end. Categories are used by [the release notes tool](../tools/Tgstation.Server.ReleaseNotes) to generate formatted changelists used in releases. The default category is Core. Only one category may be specified for a 🆑 block. -Valid categories are Core, DreamMaker API, HTTP API, Host Watchdog, Web Control Panel, Configuration, Nuget: Api, Nuget: Client, and Nuget: Common. +Valid categories are Core, DreamMaker API, REST API, GraphQL API, Host Watchdog, Web Control Panel, Configuration, Nuget: Api, Nuget: Client, and Nuget: Common. /🆑 [Why]: # (If this does not close or work on an existing GitHub issue, please add a short description [two lines down] of why you think these changes would benefit the server. If you can't justify it in words, it might not be worth adding.) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a294fb05fd..6571310526 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,15 @@ updates: interval: daily labels: - Dependencies + groups: + HotChocolate: + applies-to: version-updates + patterns: + - "HotChocolate.*" + Swashbuckle: + applies-to: version-updates + patterns: + - "Swashbuckle.*" open-pull-requests-limit: 100 - package-ecosystem: github-actions directory: / diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index e0e8354c1b..7862f0691f 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -10,7 +10,8 @@ # - Checks commit tags for deployment intents # - Deploys DreamMaker API zip [DMDeploy] (dev/master) # - Deploys Nuget Packages [NugetDeploy] (dev/master) -# - Deploys HTTP API swagger.json [APIDeploy] (dev/master) +# - Deploys HTTP API swagger.json [RESTDeploy] (dev/master) +# - Deploys GraphQL API schema.graphql [GQLDeploy] (dev/master) # - Deploys tgstation-server [TGSDeploy] (master) # - GitHub Releases: https://github.com/tgstation/tgstation-server/releases # - Docker: https://hub.docker.com/r/tgstation/server @@ -40,7 +41,6 @@ env: TGS_WEBPANEL_NODE_VERSION: 20.x TGS_TEST_GITHUB_TOKEN: ${{ secrets.LIVE_TESTS_TOKEN }} PACKAGING_PRIVATE_KEY_PASSPHRASE: ${{ secrets.PACKAGING_PRIVATE_KEY_PASSPHRASE }} - Internal__EnableGraphQL: true concurrency: group: "ci-${{ (github.event_name != 'push' && github.event_name != 'schedule' && github.event.inputs.pull_request_number) || github.run_id }}-${{ github.event_name }}" @@ -51,6 +51,11 @@ jobs: name: Build ReleaseNotes for Other Jobs runs-on: ubuntu-latest steps: + - name: Install Native Dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgdiplus + - name: Setup dotnet uses: actions/setup-dotnet@v4 with: @@ -84,6 +89,11 @@ jobs: env: TGS_TELEMETRY_KEY_FILE: /tmp/tgs_telemetry_key.txt steps: + - name: Install Native Dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgdiplus + - name: Setup dotnet uses: actions/setup-dotnet@v4 with: @@ -578,7 +588,6 @@ jobs: configuration: ["Debug", "Release"] env: TGS_TELEMETRY_KEY_FILE: C:/tgs_telemetry_key.txt - TGS_TEST_GRAPHQL: true runs-on: windows-latest steps: - name: Setup dotnet @@ -627,6 +636,7 @@ jobs: run: | echo "TGS_TEST_DATABASE_TYPE=PostgresSql" >> $GITHUB_ENV echo "TGS_TEST_CONNECTION_STRING=Application Name=tgstation-server;Host=127.0.0.1;Username=$USER;Database=TGS__${{ matrix.watchdog-type }}_${{ matrix.configuration }}" >> $GITHUB_ENV + echo "TGS_TEST_GRAPHQL=true" >> $GITHUB_ENV - name: Setup MariaDB uses: ankane/setup-mariadb@v1 @@ -638,6 +648,7 @@ jobs: run: | echo "TGS_TEST_DATABASE_TYPE=MariaDB" >> $GITHUB_ENV echo "TGS_TEST_CONNECTION_STRING=Server=127.0.0.1;uid=root;database=tgs__${{ matrix.watchdog-type }}_${{ matrix.configuration }}" >> $GITHUB_ENV + echo "TGS_TEST_GRAPHQL=true" >> $GITHUB_ENV - name: Setup MySQL uses: ankane/setup-mysql@v1 @@ -657,6 +668,7 @@ jobs: TGS_CONNSTRING_VALUE="Server=(localdb)\MSSQLLocalDB;Encrypt=false;Integrated Security=true;Initial Catalog=TGS_${{ matrix.watchdog-type }}_${{ matrix.configuration }};Application Name=tgstation-server" echo "TGS_TEST_CONNECTION_STRING=$(echo $TGS_CONNSTRING_VALUE)" >> $GITHUB_ENV echo "TGS_TEST_DATABASE_TYPE=SqlServer" >> $GITHUB_ENV + echo "TGS_TEST_GRAPHQL=true" >> $GITHUB_ENV - name: Checkout (Branch) uses: actions/checkout@v4 @@ -739,6 +751,13 @@ jobs: name: openapi-spec path: C:/tgs_api.json + - name: Store GraphQL Schema + if: ${{ matrix.configuration == 'Release' && matrix.watchdog-type == 'Advanced' && matrix.database-type == 'SqlServer' }} + uses: actions/upload-artifact@v4 + with: + name: graphql-schema + path: ./artifacts/tgs-api.graphql + - name: Package Server Service if: ${{ matrix.configuration == 'Release' && matrix.watchdog-type == 'Basic' && matrix.database-type == 'PostgresSql' }} run: | @@ -855,6 +874,7 @@ jobs: run: | echo "TGS_TEST_DATABASE_TYPE=Sqlite" >> $GITHUB_ENV echo "TGS_TEST_CONNECTION_STRING=Data Source=TGS_${{ matrix.watchdog-type }}_${{ matrix.configuration }}.sqlite3;Mode=ReadWriteCreate" >> $GITHUB_ENV + echo "TGS_TEST_GRAPHQL=true" >> $GITHUB_ENV - name: Set PostgresSql Connection Info if: ${{ matrix.database-type == 'PostgresSql' }} @@ -874,6 +894,7 @@ jobs: echo "TGS_TEST_DATABASE_TYPE=MySql" >> $GITHUB_ENV echo "TGS_TEST_CONNECTION_STRING=Server=127.0.0.1;Port=3307;uid=root;pwd=mysql;database=tgs__${{ matrix.watchdog-type }}_${{ matrix.configuration }}" >> $GITHUB_ENV echo "Database__ServerVersion=5.7.31" >> $GITHUB_ENV + echo "TGS_TEST_GRAPHQL=true" >> $GITHUB_ENV - name: Set General__UseBasicWatchdog if: ${{ matrix.watchdog-type == 'Basic' }} @@ -1348,7 +1369,7 @@ jobs: - name: Test Install run: | sudo mkdir /etc/tgstation-server - sudo cp build/package/appsettings.GitHub.yml /etc/tgstation-server/appsettings.Production.yml + sudo cp build/package/deb/appsettings.GitHub.yml /etc/tgstation-server/appsettings.Production.yml sudo apt-get install -y ./tgstation-server_${{ env.TGS_VERSION }}-1_amd64.deb sudo ls -al /etc/tgstation-server sudo cat /etc/tgstation-server/appsettings.Production.yml @@ -1358,8 +1379,32 @@ jobs: cat /usr/bin/tgs-configure - name: Test Service + id: test-service run: | + set +e systemctl status tgstation-server + retval=$? + set -e + if [[ $retval -ne 0 ]]; then + echo "succeeded=NO" >> $GITHUB_OUTPUT + else + echo "succeeded=YES" >> $GITHUB_OUTPUT + fi + + - name: Chown Errored Service Logs + if: ${{ steps.test-service.outputs.succeeded == 'NO' }} + run: sudo chown -R runner /var/log/tgstation-server + + - name: Store Errored Service Logs + if: ${{ steps.test-service.outputs.succeeded == 'NO' }} + uses: actions/upload-artifact@v4 + with: + name: systemd-service-logs + path: /var/log/tgstation-server/**/* + + - name: Fail if Service Errored + if: ${{ steps.test-service.outputs.succeeded == 'NO' }} + run: exit 1 - name: Test Uninstall # Wait 10s for service to initialize shell: bash @@ -1469,7 +1514,7 @@ jobs: shell: powershell # If it's missing, I found that in elements were the problem run: | mkdir C:/ProgramData/tgstation-server - cp build/package/appsettings.GitHub.yml C:/ProgramData/tgstation-server/appsettings.Production.yml + cp build/package/winget/appsettings.GitHub.yml C:/ProgramData/tgstation-server/appsettings.Production.yml $file = [System.IO.Path]::GetFullPath("build/package/winget/test-installer.exe") $log = [System.IO.Path]::GetFullPath("install.log") $procMain = Start-Process -FilePath $file "/install /silent /log `"$log`"" -NoNewWindow -PassThru -Wait @@ -1577,6 +1622,7 @@ jobs: run: dotnet release_notes_bins/Tgstation.Server.ReleaseNotes.dll --winget-template-check ${{ steps.get-sha.outputs.pr_template_sha }} ci-completion-gate: + if: always() && !cancelled() name: CI Completion Gate needs: [ @@ -1592,8 +1638,10 @@ jobs: ] runs-on: ubuntu-latest steps: - - name: Mandatory Empty Step - run: exit 0 + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@223e4bb7a751b91f43eda76992bcfbf23b8b0302 + with: + jobs: ${{ toJSON(needs) }} deployment-gate: name: Deployment Start Gate @@ -1604,11 +1652,11 @@ jobs: - name: GitHub Requires at Least One Step for a Job run: exit 0 - deploy-http: - name: Deploy HTTP API + deploy-rest: + name: Deploy REST API needs: deployment-gate runs-on: windows-latest - if: contains(github.event.head_commit.message, '[APIDeploy]') + if: contains(github.event.head_commit.message, '[RESTDeploy]') steps: - name: Setup dotnet uses: actions/setup-dotnet@v4 @@ -1623,7 +1671,7 @@ jobs: shell: powershell run: | [XML]$versionXML = Get-Content build/Version.props - $apiVersion = $versionXML.Project.PropertyGroup.TgsApiVersion + $apiVersion = $versionXML.Project.PropertyGroup.TgsRestVersion echo "TGS_API_VERSION=$apiVersion" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append - name: Retrieve OpenAPI Spec @@ -1654,7 +1702,7 @@ jobs: - name: Generate Release Notes env: TGS_RELEASE_NOTES_TOKEN: ${{ steps.app-token-generation.outputs.token }} - run: dotnet release_notes_bins/Tgstation.Server.ReleaseNotes.dll ${{ env.TGS_API_VERSION }} --httpapi + run: dotnet release_notes_bins/Tgstation.Server.ReleaseNotes.dll ${{ env.TGS_API_VERSION }} --restapi - name: Create GitHub Release uses: actions/create-release@v1 @@ -1663,7 +1711,7 @@ jobs: GITHUB_TOKEN: ${{ steps.app-token-generation.outputs.token }} with: tag_name: api-v${{ env.TGS_API_VERSION }} - release_name: tgstation-server API v${{ env.TGS_API_VERSION }} + release_name: tgstation-server REST API v${{ env.TGS_API_VERSION }} body_path: release_notes.md commitish: ${{ github.event.head_commit.id }} @@ -1677,6 +1725,86 @@ jobs: asset_name: swagger.json asset_content_type: application/json + deploy-gql: + name: Deploy GraphQL API + needs: deployment-gate + runs-on: windows-latest + if: contains(github.event.head_commit.message, '[GQLDeploy]') + steps: + - name: Setup dotnet + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.TGS_DOTNET_VERSION }}.0.x + dotnet-quality: ${{ env.TGS_DOTNET_QUALITY }} + + - name: Checkout + uses: actions/checkout@v4 + + - name: Parse API version + shell: powershell + run: | + [XML]$versionXML = Get-Content build/Version.props + $apiVersion = $versionXML.Project.PropertyGroup.TgsGraphQLVersion + echo "TGS_API_VERSION=$apiVersion" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append + [Version]$parsedVersion = $apiVersion + if ($parsedVersion.Major -eq 0) { + echo "TGS_GRAPHQL_PRERELEASE=true" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append + } else { + echo "TGS_GRAPHQL_PRERELEASE=false" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append + } + + - name: Retrieve GraphQL Schema + uses: actions/download-artifact@v4 + with: + name: graphql-schema + path: schema + + - name: Grab Most Recent Changelog + shell: powershell + run: | + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri https://raw.githubusercontent.com/tgstation/tgstation-server/gh-pages/changelog.yml -OutFile changelog.yml + + - name: Retrieve ReleaseNotes Binaries + uses: actions/download-artifact@v4 + with: + name: release_notes_bins + path: release_notes_bins + + - name: Generate App Token + id: app-token-generation + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Generate Release Notes + env: + TGS_RELEASE_NOTES_TOKEN: ${{ steps.app-token-generation.outputs.token }} + run: dotnet release_notes_bins/Tgstation.Server.ReleaseNotes.dll ${{ env.TGS_API_VERSION }} --graphqlapi + + - name: Create GitHub Release + uses: actions/create-release@v1 + id: create_release + env: + GITHUB_TOKEN: ${{ steps.app-token-generation.outputs.token }} + with: + tag_name: graphql-v${{ env.TGS_API_VERSION }} + release_name: tgstation-server GraphQL API v${{ env.TGS_API_VERSION }} + body_path: release_notes.md + commitish: ${{ github.event.head_commit.id }} + prerelease: ${{ env.TGS_GRAPHQL_PRERELEASE }} + + - name: Upload GraphQL Schema + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ steps.app-token-generation.outputs.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./schema/tgs-api.graphql + asset_name: tgs-api.graphql + asset_content_type: text/plain + deploy-dm: name: Deploy DreamMaker API needs: deployment-gate @@ -1811,9 +1939,9 @@ jobs: ensure-release: name: Ensure TGS Release is Latest GitHub Release - needs: [deploy-dm, deploy-http] + needs: [deploy-dm, deploy-rest, deploy-gql] runs-on: ubuntu-latest - if: (!(cancelled() || failure())) && (!contains(github.event.head_commit.message, '[TGSDeploy]')) && (needs.deploy-dm.result == 'success' || needs.deploy-http.result == 'success') + if: (!(cancelled() || failure())) && (!contains(github.event.head_commit.message, '[TGSDeploy]')) && (needs.deploy-dm.result == 'success' || needs.deploy-rest.result == 'success' || needs.deploy-gql.result == 'success') steps: - name: Setup dotnet uses: actions/setup-dotnet@v4 @@ -1841,7 +1969,7 @@ jobs: deploy-tgs: name: Deploy TGS - needs: [deploy-dm, deploy-http, deployment-gate] + needs: [deploy-dm, deploy-rest, deploy-gql, deployment-gate] runs-on: windows-latest if: (!(cancelled() || failure())) && github.event.ref == 'refs/heads/master' && contains(github.event.head_commit.message, '[TGSDeploy]') && needs.deployment-gate.result == 'success' env: @@ -1933,6 +2061,12 @@ jobs: name: openapi-spec path: swagger + - name: Retrieve GraphQL Schema + uses: actions/download-artifact@v4 + with: + name: graphql-schema + path: schema + - name: Retrieve Debian Packaging Archive uses: actions/download-artifact@v4 with: @@ -2028,7 +2162,7 @@ jobs: asset_name: DMAPI.zip asset_content_type: application/zip - - name: Upload OpenApi Spec Artifact + - name: Upload REST API Artifact uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ steps.app-token-generation.outputs.token }} @@ -2038,6 +2172,16 @@ jobs: asset_name: swagger.json asset_content_type: application/json + - name: Upload GraphQL API Artifact + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ steps.app-token-generation.outputs.token }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./schema/tgs-api.graphql + asset_name: tgs-api.graphql + asset_content_type: text/plain + - name: Upload Server Update Package Artifact uses: actions/upload-release-asset@v1 env: @@ -2141,7 +2285,7 @@ jobs: run: echo "TGS_VERSION=$(xmlstarlet sel -N X="http://schemas.microsoft.com/developer/msbuild/2003" --template --value-of /X:Project/X:PropertyGroup/X:TgsCoreVersion build/Version.props)" >> $GITHUB_ENV - name: Docker Build and Push - uses: elgohr/Publish-Docker-Github-Action@b77434333350079774b58dfdf2ebd57f10b31bef # v5 + uses: elgohr/Publish-Docker-Github-Action@8a77ff7e106a82f06cefb2670b851ff562c53079 # v5 with: name: tgstation/server username: ${{ secrets.DOCKER_USERNAME }} diff --git a/.github/workflows/ci-security.yml b/.github/workflows/ci-security.yml index e9b0aa55e4..67b6feec54 100644 --- a/.github/workflows/ci-security.yml +++ b/.github/workflows/ci-security.yml @@ -32,20 +32,6 @@ jobs: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - - name: Comment on new Fork PR - if: github.event.action == 'opened' && !contains(github.event.pull_request.labels.*.name, 'CI Cleared') && github.event.pull_request.user.id != 49699333 - uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 - with: - message: Thank you for contributing to ${{ github.event.pull_request.base.repo.name }}! The workflow '${{ github.workflow }}' requires repository secrets and will not run without approval. Maintainers can add the `CI Cleared` label to allow it to run. Note that any changes to ci-security.yml and ci-pipeline.yml will not be reflected. - GITHUB_TOKEN: ${{ steps.app-token-generation.outputs.token }} - - - name: Comment on dependabot PR - if: github.event.action == 'opened' && !contains(github.event.pull_request.labels.*.name, 'CI Cleared') && github.event.pull_request.user.id == 49699333 - uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 - with: - message: Set the milestone to the next ${{ (github.head_ref == 'master' && 'patch') || 'minor' }} version, check for supply chain attacks, and then add the `CI Cleared` label to allow CI to run. - GITHUB_TOKEN: ${{ steps.app-token-generation.outputs.token }} - - name: "Remove Stale 'CI Cleared' Label" if: github.event.action == 'synchronize' || github.event.action == 'reopened' uses: actions-ecosystem/action-remove-labels@2ce5d41b4b6aa8503e285553f75ed56e0a40bae0 diff --git a/.github/workflows/new-pr-comments.yml b/.github/workflows/new-pr-comments.yml new file mode 100644 index 0000000000..769b1eb492 --- /dev/null +++ b/.github/workflows/new-pr-comments.yml @@ -0,0 +1,36 @@ +name: CI Gated PR Comments + +on: + pull_request_target: + types: + - opened + branches: + - dev + - master + +jobs: + comment-on-new-pr: + name: Comment New PR + if: github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id || github.event.pull_request.user.id == 49699333 + runs-on: ubuntu-latest + steps: + - name: Generate App Token + id: app-token-generation + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Comment on new Fork PR + if: github.event.pull_request.user.id != 49699333 + uses: thollander/actions-comment-pull-request@e2c37e53a7d2227b61585343765f73a9ca57eda9 + with: + message: Thank you for contributing to ${{ github.event.pull_request.base.repo.name }}! The workflow '${{ github.workflow }}' requires repository secrets and will not run without approval. Maintainers can add the `CI Cleared` label to allow it to run. Note that any changes to ci-security.yml and ci-pipeline.yml will not be reflected. + github-token: ${{ steps.app-token-generation.outputs.token }} + + - name: Comment on dependabot PR + if: github.event.pull_request.user.id == 49699333 + uses: thollander/actions-comment-pull-request@e2c37e53a7d2227b61585343765f73a9ca57eda9 + with: + message: Set the milestone to the next ${{ (github.head_ref == 'master' && 'patch') || 'minor' }} version, check for supply chain attacks, and then add the `CI Cleared` label to allow CI to run. + github-token: ${{ steps.app-token-generation.outputs.token }} diff --git a/.github/workflows/scripts/rerunFlakyTests.js b/.github/workflows/scripts/rerunFlakyTests.js index ae4cd73d90..b570c8c78b 100644 --- a/.github/workflows/scripts/rerunFlakyTests.js +++ b/.github/workflows/scripts/rerunFlakyTests.js @@ -10,7 +10,8 @@ const CONSIDERED_STEP_PREFIXES = [ const CONSIDERED_JOBS = [ "Windows Live Tests", "Linux Live Tests", - "Build .deb Package" + "Build .deb Package", + "CI Completion Gate" ]; async function getFailedJobsForRun(github, context, workflowRunId, runAttempt) { @@ -38,7 +39,7 @@ export async function rerunFlakyTests({ github, context }) { context.payload.workflow_run.run_attempt ); - if (failingJobs.length > 3) { + if (failingJobs.length > 4) { console.log("Many jobs failing. PROBABLY not flaky, Will not re-run."); return; } diff --git a/.github/workflows/size-label.yml b/.github/workflows/size-label.yml index 8291efd0f0..7e31010cde 100644 --- a/.github/workflows/size-label.yml +++ b/.github/workflows/size-label.yml @@ -2,8 +2,7 @@ name: Size Labelling on: pull_request_target: types: - - reopened - - labeled + - opened - synchronize concurrency: diff --git a/README.md b/README.md index ed43da7fb1..a03445fd6b 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,8 @@ Create an `appsettings.Production.yml` file next to `appsettings.yml`. This will - `FileLogging:LogLevel`: Can be one of `Trace`, `Debug`, `Information`, `Warning`, `Error`, or `Critical`. Restricts what is put into the log files. Currently `Debug` is reccommended for help with error reporting. +- `FileLogging:ProviderNetworkDebug`: Boolean controlling whether or not Chat bot providers should log their raw network traffic. Currently only applies to IrcProvider. + - `Kestrel:Endpoints:Http:Url`: The URL (i.e. interface and ports) your application should listen on. General use case should be `http://localhost:` for restricted local connections. See the Remote Access section for configuring public access to the World Wide Web. This doesn't need to be changed using the docker setup and should be mapped with the `-p` option instead - `Database:DatabaseType`: Can be one of `SqlServer`, `MariaDB`, `MySql`, `PostgresSql`, or `Sqlite`. diff --git a/build/TestCommon.props b/build/TestCommon.props index 16ce4f1d66..719e316c8e 100644 --- a/build/TestCommon.props +++ b/build/TestCommon.props @@ -18,9 +18,9 @@ - + - + diff --git a/build/Version.props b/build/Version.props index 35126b239a..dc59a0b959 100644 --- a/build/Version.props +++ b/build/Version.props @@ -3,12 +3,13 @@ - 6.10.0 - 5.2.0 - 10.9.0 + 6.11.1 + 5.3.0 + 10.10.0 + 0.2.0 7.0.0 - 15.0.0 - 18.0.0 + 16.1.0 + 19.1.0 7.3.0 5.10.0 1.5.0 diff --git a/build/WebpanelVersion.props b/build/WebpanelVersion.props index 6d60788f20..d3c0595003 100644 --- a/build/WebpanelVersion.props +++ b/build/WebpanelVersion.props @@ -1,6 +1,6 @@ - 6.2.0 + 6.4.0 diff --git a/build/analyzers.ruleset b/build/analyzers.ruleset index 72be43fd43..15ba71dc89 100644 --- a/build/analyzers.ruleset +++ b/build/analyzers.ruleset @@ -1,4 +1,4 @@ - + @@ -6,7 +6,6 @@ - diff --git a/build/package/deb/appsettings.GitHub.yml b/build/package/deb/appsettings.GitHub.yml new file mode 100644 index 0000000000..ae79a502fc --- /dev/null +++ b/build/package/deb/appsettings.GitHub.yml @@ -0,0 +1,26 @@ +# Barebones config file used for testing service installs in GitHub actions +Database: + DatabaseType: Sqlite + ConnectionString: Data Source=/home/tgstation-server/tgs.sqlite3;Mode=ReadWriteCreate +General: + ConfigVersion: 4.6.0 + ApiPort: 5000 + GitHubAccessToken: + SetupWizardMode: Never + ByondTopicTimeout: 5000 + RestartTimeoutMinutes: 1 + ShutdownTimeoutMinutes: 300 + UseBasicWatchdog: false + HostApiDocumentation: true + SkipAddingByondFirewallException: false + DeploymentDirectoryCopyTasksPerCore: + MinimumPasswordLength: 15 + InstanceLimit: 10 + UserLimit: 100 + UserGroupLimit: 25 + ValidInstancePaths: +FileLogging: + Directory: + Disable: false + LogLevel: Trace + MicrosoftLogLevel: Warning diff --git a/build/package/winget/.config/dotnet-tools.json b/build/package/winget/.config/dotnet-tools.json index 86e122aea1..588fcd1095 100644 --- a/build/package/winget/.config/dotnet-tools.json +++ b/build/package/winget/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "wix": { - "version": "5.0.1", + "version": "5.0.2", "commands": [ "wix" ] diff --git a/build/package/winget/Tgstation.Server.Host.Service.Wix.Bundle/Tgstation.Server.Host.Service.Wix.Bundle.wixproj b/build/package/winget/Tgstation.Server.Host.Service.Wix.Bundle/Tgstation.Server.Host.Service.Wix.Bundle.wixproj index 8448e29fdf..df2363b5ac 100644 --- a/build/package/winget/Tgstation.Server.Host.Service.Wix.Bundle/Tgstation.Server.Host.Service.Wix.Bundle.wixproj +++ b/build/package/winget/Tgstation.Server.Host.Service.Wix.Bundle/Tgstation.Server.Host.Service.Wix.Bundle.wixproj @@ -1,4 +1,4 @@ - + ProductVersion=$(TgsCoreVersion);NetMajorVersion=$(TgsNetMajorVersion);DotnetRedistUrl=$(TgsDotnetRedistUrl);MariaDBRedistUrl=https://github.com/tgstation/tgstation-server/releases/download/tgstation-server-v$(TgsCoreVersion)/mariadb-$(TgsMariaDBRedistVersion)-winx64.msi @@ -24,8 +24,8 @@ - - + + diff --git a/build/package/winget/Tgstation.Server.Host.Service.Wix.Extensions/Tgstation.Server.Host.Service.Wix.Extensions.csproj b/build/package/winget/Tgstation.Server.Host.Service.Wix.Extensions/Tgstation.Server.Host.Service.Wix.Extensions.csproj index bf40086eea..fcdbd512a6 100644 --- a/build/package/winget/Tgstation.Server.Host.Service.Wix.Extensions/Tgstation.Server.Host.Service.Wix.Extensions.csproj +++ b/build/package/winget/Tgstation.Server.Host.Service.Wix.Extensions/Tgstation.Server.Host.Service.Wix.Extensions.csproj @@ -7,7 +7,7 @@ - + diff --git a/build/package/winget/Tgstation.Server.Host.Service.Wix/Tgstation.Server.Host.Service.Wix.wixproj b/build/package/winget/Tgstation.Server.Host.Service.Wix/Tgstation.Server.Host.Service.Wix.wixproj index 1bc4df76fc..9ca13531e2 100644 --- a/build/package/winget/Tgstation.Server.Host.Service.Wix/Tgstation.Server.Host.Service.Wix.wixproj +++ b/build/package/winget/Tgstation.Server.Host.Service.Wix/Tgstation.Server.Host.Service.Wix.wixproj @@ -1,4 +1,4 @@ - + ProductVersion=$(TgsCoreVersion) @@ -25,8 +25,8 @@ - - + + diff --git a/build/package/appsettings.GitHub.yml b/build/package/winget/appsettings.GitHub.yml similarity index 100% rename from build/package/appsettings.GitHub.yml rename to build/package/winget/appsettings.GitHub.yml diff --git a/src/README.md b/src/README.md index 76cb6bdf5d..c05d859bf2 100644 --- a/src/README.md +++ b/src/README.md @@ -5,7 +5,7 @@ This is a series of README.md files aimed at directing people around the codebas - To explore the server code navigate to [Tgstation.Server.Host](./Tgstation.Server.Host). - To explore the DreamMaker public API navigate to [DMAPI/tgs.dm](./DMAPI/tgs.dm). - To explore the DMAPI internals, navigate to [DMAPI/tgs](./DMAPI/tgs). -- To explore the HTTP API definitions navigate to [Tgstation.Server.Api](./Tgstation.Server.Api). +- To explore the REST API definitions navigate to [Tgstation.Server.Api](./Tgstation.Server.Api). - To explore the C# client code navigate to [Tgstation.Server.Client](./Tgstation.Server.Client). [Tgstation.Server.Host.Watchdog](./Tgstation.Server.Host.Watchdog), [Tgstation.Server.Host.Service](./Tgstation.Server.Host.Service), and [Tgstation.Server.Host.Console](./Tgstation.Server.Host.Console) are related to the Service/Console runners which have the simple task of executing Tgstation.Server.Host and updating it when requested. diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index fad13a0330..2f670f5029 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -76,7 +76,7 @@ namespace Tgstation.Server.Api /// /// A containing the ':' . /// - static readonly char[] ColonSeparator = new char[] { ':' }; + static readonly char[] ColonSeparator = [':']; /// /// The instance being accessed. @@ -214,7 +214,7 @@ namespace Tgstation.Server.Api var jsonAccept = new Microsoft.Net.Http.Headers.MediaTypeHeaderValue(ApplicationJsonMime); var eventStreamAccept = new Microsoft.Net.Http.Headers.MediaTypeHeaderValue(TextEventStreamMime); - if (!requestHeaders.Accept.Any(jsonAccept.IsSubsetOf)) + if (!requestHeaders.Accept.Any(accept => accept.IsSubsetOf(jsonAccept))) if (!allowEventStreamAccept) AddError(HeaderErrorTypes.Accept, $"Client does not accept {ApplicationJsonMime}!"); else if (!requestHeaders.Accept.Any(eventStreamAccept.IsSubsetOf)) @@ -269,7 +269,7 @@ namespace Tgstation.Server.Api if (requestHeaders.Headers.TryGetValue(OAuthProviderHeader, out StringValues oauthProviderValues)) { var oauthProviderString = oauthProviderValues.First(); - if (Enum.TryParse(oauthProviderString, out var oauthProvider)) + if (Enum.TryParse(oauthProviderString, true, out var oauthProvider)) OAuthProvider = oauthProvider; else AddError(HeaderErrorTypes.OAuthProvider, "Invalid OAuth provider!"); @@ -358,8 +358,9 @@ namespace Tgstation.Server.Api /// /// Checks if the is compatible with . /// + /// The that can alternatively be used as the . /// if the API is compatible, otherwise. - public bool Compatible() => CheckCompatibility(ApiVersion); + public bool Compatible(Version? alternateApiVersion = null) => CheckCompatibility(ApiVersion) || (alternateApiVersion != null && alternateApiVersion.Major == ApiVersion.Major); /// /// Set using the . This initially clears . diff --git a/src/Tgstation.Server.Api/Models/ErrorCode.cs b/src/Tgstation.Server.Api/Models/ErrorCode.cs index 052941d922..37a61e55fb 100644 --- a/src/Tgstation.Server.Api/Models/ErrorCode.cs +++ b/src/Tgstation.Server.Api/Models/ErrorCode.cs @@ -663,5 +663,11 @@ namespace Tgstation.Server.Api.Models /// [Description("Provided repository username doesn't match the user of the corresponding access token!")] RepoTokenUsernameMismatch, + + /// + /// Attempted to make a cross swarm server request using the GraphQL API. + /// + [Description("GraphQL swarm remote gateways not implemented!")] + RemoteGatewaysNotImplemented, } } diff --git a/src/Tgstation.Server.Api/Models/Internal/LocalServerInformation.cs b/src/Tgstation.Server.Api/Models/Internal/LocalServerInformation.cs deleted file mode 100644 index ae8d5ca34e..0000000000 --- a/src/Tgstation.Server.Api/Models/Internal/LocalServerInformation.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; - -namespace Tgstation.Server.Api.Models.Internal -{ - /// - /// Information about the local tgstation-server. - /// - public class LocalServerInformation : ServerInformationBase - { - /// - /// If the server is running on a windows operating system. - /// - public bool WindowsHost { get; set; } - - /// - /// Map of to the for them. - /// - public Dictionary? OAuthProviderInfos { get; set; } - } -} diff --git a/src/Tgstation.Server.Api/Models/Internal/ServerInformationBase.cs b/src/Tgstation.Server.Api/Models/Internal/ServerInformationBase.cs index 30d379a323..15fafe90b1 100644 --- a/src/Tgstation.Server.Api/Models/Internal/ServerInformationBase.cs +++ b/src/Tgstation.Server.Api/Models/Internal/ServerInformationBase.cs @@ -31,6 +31,6 @@ namespace Tgstation.Server.Api.Models.Internal /// Limits the locations instances may be created or attached from. /// [ResponseOptions] - public ICollection? ValidInstancePaths { get; set; } + public List? ValidInstancePaths { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/Internal/SwarmServer.cs b/src/Tgstation.Server.Api/Models/Internal/SwarmServer.cs index 9205e58a09..998c519b19 100644 --- a/src/Tgstation.Server.Api/Models/Internal/SwarmServer.cs +++ b/src/Tgstation.Server.Api/Models/Internal/SwarmServer.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Models.Internal /// /// Information about a server in the swarm. /// - public abstract class SwarmServer : IEquatable + public abstract class SwarmServer { /// /// The public address of the server. @@ -47,12 +47,5 @@ namespace Tgstation.Server.Api.Models.Internal PublicAddress = copy.PublicAddress; Identifier = copy.Identifier; } - - /// - public bool Equals(SwarmServer other) - => other != null - && other.Identifier == Identifier - && other.PublicAddress == PublicAddress - && other.Address == Address; } } diff --git a/src/Tgstation.Server.Api/Models/Internal/SwarmServerInformation.cs b/src/Tgstation.Server.Api/Models/Internal/SwarmServerInformation.cs index fb24f74ad1..89238b3099 100644 --- a/src/Tgstation.Server.Api/Models/Internal/SwarmServerInformation.cs +++ b/src/Tgstation.Server.Api/Models/Internal/SwarmServerInformation.cs @@ -1,13 +1,11 @@ -using System; - -using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Api.Models.Response; namespace Tgstation.Server.Api.Models.Internal { /// /// Represents information about a running . /// - public class SwarmServerInformation : SwarmServer, IEquatable + public class SwarmServerInformation : SwarmServer { /// /// If the is the controller. @@ -30,10 +28,5 @@ namespace Tgstation.Server.Api.Models.Internal { Controller = copy.Controller; } - - /// - public bool Equals(SwarmServerInformation other) - => base.Equals(other) - && other.Controller == Controller; } } diff --git a/src/Tgstation.Server.Api/Models/IrcConnectionStringBuilder.cs b/src/Tgstation.Server.Api/Models/IrcConnectionStringBuilder.cs index b4cb64a811..18e8a14df6 100644 --- a/src/Tgstation.Server.Api/Models/IrcConnectionStringBuilder.cs +++ b/src/Tgstation.Server.Api/Models/IrcConnectionStringBuilder.cs @@ -88,6 +88,7 @@ namespace Tgstation.Server.Api.Models case IrcPasswordType.NickServ: case IrcPasswordType.Sasl: case IrcPasswordType.Server: + case IrcPasswordType.Oper: PasswordType = passwordType; break; default: diff --git a/src/Tgstation.Server.Api/Models/IrcPasswordType.cs b/src/Tgstation.Server.Api/Models/IrcPasswordType.cs index add54b1565..9a0fca38c4 100644 --- a/src/Tgstation.Server.Api/Models/IrcPasswordType.cs +++ b/src/Tgstation.Server.Api/Models/IrcPasswordType.cs @@ -19,5 +19,10 @@ /// Use NickServ authentication. /// NickServ, + + /// + /// Use OPER authentication. + /// + Oper, } } diff --git a/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs b/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs index a33b2b8be2..4e80e80564 100644 --- a/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs +++ b/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Models.Response /// /// Represents basic server information. /// - public sealed class ServerInformationResponse : Internal.LocalServerInformation + public sealed class ServerInformationResponse : Internal.ServerInformationBase { /// /// The version of the host. @@ -23,6 +23,16 @@ namespace Tgstation.Server.Api.Models.Response /// public Version? DMApiVersion { get; set; } + /// + /// If the server is running on a windows operating system. + /// + public bool WindowsHost { get; set; } + + /// + /// Map of to the for them. + /// + public Dictionary? OAuthProviderInfos { get; set; } + /// /// If there is a server update in progress. /// diff --git a/src/Tgstation.Server.Api/Models/UserName.cs b/src/Tgstation.Server.Api/Models/UserName.cs index 779f951916..a3b6be8162 100644 --- a/src/Tgstation.Server.Api/Models/UserName.cs +++ b/src/Tgstation.Server.Api/Models/UserName.cs @@ -25,7 +25,7 @@ /// The child of to create. /// A new copied from . protected virtual TResultType CreateUserName() - where TResultType : UserName, new() => new TResultType + where TResultType : UserName, new() => new() { Id = Id, Name = Name, diff --git a/src/Tgstation.Server.Api/Rights/RightsHelper.cs b/src/Tgstation.Server.Api/Rights/RightsHelper.cs index 2f60e9a0a0..424d4608d5 100644 --- a/src/Tgstation.Server.Api/Rights/RightsHelper.cs +++ b/src/Tgstation.Server.Api/Rights/RightsHelper.cs @@ -47,19 +47,13 @@ namespace Tgstation.Server.Api.Rights /// /// The . /// The . - /// A representing the claim role name. - public static string RoleNames(TRight right) + /// Am of s representing the claim role names. + public static IEnumerable RoleNames(TRight right) where TRight : Enum { - IEnumerable GetRoleNames() - { - foreach (Enum rightValue in Enum.GetValues(right.GetType())) - if (Convert.ToInt32(rightValue, CultureInfo.InvariantCulture) != 0 && right.HasFlag(rightValue)) - yield return String.Concat(typeof(TRight).Name, '.', rightValue.ToString()); - } - - var names = GetRoleNames(); - return String.Join(",", names); + foreach (Enum rightValue in Enum.GetValues(right.GetType())) + if (Convert.ToInt32(rightValue, CultureInfo.InvariantCulture) != 0 && right.HasFlag(rightValue)) + yield return String.Concat(typeof(TRight).Name, '.', rightValue.ToString()); } /// diff --git a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj index bf191c77fd..73092b2ba6 100644 --- a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj +++ b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj @@ -14,7 +14,7 @@ - <_Parameter1>$(TgsApiVersion) + <_Parameter1>$(TgsRestVersion) @@ -28,7 +28,7 @@ - + diff --git a/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json b/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json index 0b651ad84f..a6148380bc 100644 --- a/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json +++ b/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json @@ -13,7 +13,7 @@ "transportProfiles": [ { "default": "Http", - "subscription": "WebSocket" + "subscription": "Http" } ] } diff --git a/src/Tgstation.Server.Client.GraphQL/AuthenticatedGraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/AuthenticatedGraphQLServerClient.cs new file mode 100644 index 0000000000..f69418d5a5 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/AuthenticatedGraphQLServerClient.cs @@ -0,0 +1,97 @@ +using System; +using System.Net.Http.Headers; +using System.Threading.Tasks; + +using Microsoft.Extensions.Logging; + +using StrawberryShake; + +using Tgstation.Server.Common.Extensions; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + sealed class AuthenticatedGraphQLServerClient : GraphQLServerClient, IAuthenticatedGraphQLServerClient + { + /// + public ITransferClient TransferClient => restClient!.Transfer; + + /// + /// A that takes a bearer token as input and outputs a that uses it. + /// + readonly Func? getRestClientForToken; + + /// + /// The current . + /// + IRestServerClient? restClient; + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The to use. + /// The to use. + /// The value of . + public AuthenticatedGraphQLServerClient( + IGraphQLClient graphQLClient, + IAsyncDisposable serviceProvider, + ILogger logger, + IRestServerClient restClient) + : base(graphQLClient, serviceProvider, logger) + { + this.restClient = restClient ?? throw new ArgumentNullException(nameof(restClient)); + } + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The to use. + /// The to use. + /// The to call to set the async local for requests. + /// The basic to use for reauthentication. + /// The containing the initial JWT to use. + /// The value of . + public AuthenticatedGraphQLServerClient( + IGraphQLClient graphQLClient, + IAsyncDisposable serviceProvider, + ILogger logger, + Action setAuthenticationHeader, + AuthenticationHeaderValue? basicCredentialsHeader, + IOperationResult loginResult, + Func getRestClientForToken) + : base( + graphQLClient, + serviceProvider, + logger, + setAuthenticationHeader, + basicCredentialsHeader, + loginResult) + { + this.getRestClientForToken = getRestClientForToken ?? throw new ArgumentNullException(nameof(getRestClientForToken)); + restClient = getRestClientForToken(loginResult.Data!.Login.LoginResult!.Bearer.EncodedToken); + } + + /// + public sealed override ValueTask DisposeAsync() +#pragma warning disable CA2012 // Use ValueTasks correctly + => ValueTaskExtensions.WhenAll( + base.DisposeAsync(), + restClient!.DisposeAsync()); +#pragma warning restore CA2012 // Use ValueTasks correctly + + /// + protected sealed override async ValueTask CreateUpdatedAuthenticationHeader(string bearer) + { + var baseTask = base.CreateUpdatedAuthenticationHeader(bearer); + if (restClient != null) + await restClient.DisposeAsync().ConfigureAwait(false); + + if (getRestClientForToken != null) + restClient = getRestClientForToken(bearer); + + return await baseTask.ConfigureAwait(false); + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/AuthenticationException.cs b/src/Tgstation.Server.Client.GraphQL/AuthenticationException.cs new file mode 100644 index 0000000000..d02134f6e3 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/AuthenticationException.cs @@ -0,0 +1,51 @@ +using System; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + /// thrown when automatic authentication fails. + /// + public sealed class AuthenticationException : Exception + { + /// + /// The . + /// + public ILogin_Login_Errors_ErrorMessageError? ErrorMessage { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public AuthenticationException(ILogin_Login_Errors_ErrorMessageError errorMessage) + : base(errorMessage?.Message) + { + ErrorMessage = errorMessage ?? throw new ArgumentNullException(nameof(errorMessage)); + } + + /// + /// Initializes a new instance of the class. + /// + public AuthenticationException() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + public AuthenticationException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + public AuthenticationException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/AuthorizationMessageHandler.cs b/src/Tgstation.Server.Client.GraphQL/AuthorizationMessageHandler.cs new file mode 100644 index 0000000000..949ac80f92 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/AuthorizationMessageHandler.cs @@ -0,0 +1,42 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + /// that applies the . + /// + sealed class AuthorizationMessageHandler : DelegatingHandler + { + /// + /// The to be applied. + /// + public static AsyncLocal Header { get; } = new AsyncLocal(); + + /// + /// override for . + /// + readonly AuthenticationHeaderValue? headerOverride; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public AuthorizationMessageHandler(AuthenticationHeaderValue? headerOverride) + { + this.headerOverride = headerOverride; + } + + /// + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var currentAuthHeader = headerOverride ?? Header.Value; + if (currentAuthHeader != null) + request.Headers.Authorization = currentAuthHeader; + + return base.SendAsync(request, cancellationToken); + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateSystemUserWithPermissionSet.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateSystemUserWithPermissionSet.graphql new file mode 100644 index 0000000000..5c9e176dc4 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateSystemUserWithPermissionSet.graphql @@ -0,0 +1,16 @@ +mutation CreateSystemUserWithPermissionSet($systemIdentifier: String!) { + createUserBySystemIDAndPermissionSet( + input: { permissionSet: {}, systemIdentifier: $systemIdentifier } + ) { + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + user { + id + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserFromOAuthConnection.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserFromOAuthConnection.graphql new file mode 100644 index 0000000000..9d374b5ff0 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserFromOAuthConnection.graphql @@ -0,0 +1,14 @@ +mutation CreateUserFromOAuthConnection($name: String!, $oAuthConnections: [OAuthConnectionInput!]!) { + createUserByOAuthAndPermissionSet(input: { name: $name, oAuthConnections: $oAuthConnections }) { + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + user { + id + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroup.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroup.graphql new file mode 100644 index 0000000000..2d8489ecb6 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroup.graphql @@ -0,0 +1,40 @@ +mutation CreateUserGroup($name: String!) { + createUserGroup(input: { name: $name }) { + userGroup { + id + name + permissionSet { + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnOAuthConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + } + } + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroupWithInstanceListPerm.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroupWithInstanceListPerm.graphql new file mode 100644 index 0000000000..6a99c7ede0 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroupWithInstanceListPerm.graphql @@ -0,0 +1,40 @@ +mutation CreateUserGroupWithInstanceListPerm($name: String!) { + createUserGroup(input: { name: $name, permissionSet: { instanceManagerRights: { canList: true } } }) { + userGroup { + id + name + permissionSet { + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnOAuthConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + } + } + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPassword.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPassword.graphql new file mode 100644 index 0000000000..8f330b0364 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPassword.graphql @@ -0,0 +1,14 @@ +mutation CreateUserWithPassword($name: String!, $password: String!) { + createUserByPasswordAndPermissionSet(input: { name: $name, password: $password }) { + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + user { + id + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPasswordSelectOAuthConnections.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPasswordSelectOAuthConnections.graphql new file mode 100644 index 0000000000..2d6b335889 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPasswordSelectOAuthConnections.graphql @@ -0,0 +1,18 @@ +mutation CreateUserWithPasswordSelectOAuthConnections($name: String!, $password: String!) { + createUserByPasswordAndPermissionSet(input: { name: $name, password: $password }) { + user { + id + oAuthConnections { + externalUserId + provider + } + } + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/DeleteUserGroup.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/DeleteUserGroup.graphql new file mode 100644 index 0000000000..0b94be4e05 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/DeleteUserGroup.graphql @@ -0,0 +1,11 @@ +mutation DeleteUserGroup($id: ID!) { + deleteEmptyUserGroup(input: { id: $id }) { + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/Login.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/Login.graphql new file mode 100644 index 0000000000..2fb2e9a95b --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/Login.graphql @@ -0,0 +1,14 @@ +mutation Login { + login { + loginResult { + bearer + } + errors { + ... on ErrorMessageError { + message + errorCode + additionalData + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetFullPermsOnUserGroup.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetFullPermsOnUserGroup.graphql new file mode 100644 index 0000000000..1766cb4149 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetFullPermsOnUserGroup.graphql @@ -0,0 +1,69 @@ +mutation SetFullPermsOnUserGroup($id: ID!) { + updateUserGroup( + input: { + id: $id + newPermissionSet: { + administrationRights: { + canChangeVersion: true + canDownloadLogs: true + canEditOwnOAuthConnections: true + canEditOwnPassword: true + canReadUsers: true + canWriteUsers: true + canUploadVersion: true + canRestartHost: true + } + instanceManagerRights: { + canCreate: true + canDelete: true + canGrantPermissions: true + canSetOnline: true + canSetConfiguration: true + canSetChatBotLimit: true + canSetAutoUpdate: true + canRename: true + canRead: true + canList: true + canRelocate: true + } + } + } + ) { + userGroup { + id + name + permissionSet { + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnOAuthConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + } + } + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserGroup.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserGroup.graphql new file mode 100644 index 0000000000..d68865e550 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserGroup.graphql @@ -0,0 +1,41 @@ +mutation SetUserGroup($id: ID!, $newGroupId: ID!) { + updateUserSetGroup(input: { id: $id, newGroupId: $newGroupId }) { + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + user { + ownedPermissionSet { + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnOAuthConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + } + group { + id + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserOAuthConnections.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserOAuthConnections.graphql new file mode 100644 index 0000000000..81746cf0b4 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserOAuthConnections.graphql @@ -0,0 +1,25 @@ +mutation SetUserOAuthConnections($id: ID!, $newOAuthConnections: [OAuthConnectionInput!]!) { + updateUser( + input: { id: $id, newOAuthConnections: $newOAuthConnections } + ) { + user { + canonicalName + createdAt + enabled + id + name + systemIdentifier + oAuthConnections { + externalUserId + provider + } + } + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserPermissionSet.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserPermissionSet.graphql new file mode 100644 index 0000000000..a1dbcb24e6 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserPermissionSet.graphql @@ -0,0 +1,66 @@ +mutation SetUserPermissionSet($id: ID!, $permissionSet: PermissionSetInput!) { + updateUserSetOwnedPermissionSet(input: { newPermissionSet: $permissionSet, id: $id }) { + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + user { + effectivePermissionSet { + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnOAuthConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + } + ownedPermissionSet { + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnOAuthConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + } + group { + id + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/UpdateUserOAuthConnections.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/UpdateUserOAuthConnections.graphql new file mode 100644 index 0000000000..8cd18d2dc5 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/UpdateUserOAuthConnections.graphql @@ -0,0 +1,18 @@ +mutation UpdateUserOAuthConnections($id: ID!, $newOAuthConnections: [OAuthConnectionInput!]) { + updateUser(input: { id: $id, newOAuthConnections: $newOAuthConnections }) { + user { + id + oAuthConnections { + externalUserId + provider + } + } + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetSomeGroupInfo.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetSomeGroupInfo.graphql new file mode 100644 index 0000000000..f36dee754a --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetSomeGroupInfo.graphql @@ -0,0 +1,41 @@ +query GetSomeGroupInfo($id: ID!) { + swarm { + users { + groups { + byId(id: $id) { + permissionSet { + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnOAuthConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + } + queryableUsersByGroup(first: 1) { + totalCount + nodes { + id + } + } + } + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUserById.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUserById.graphql new file mode 100644 index 0000000000..1dc6c3a8de --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUserById.graphql @@ -0,0 +1,14 @@ +query GetUserById($id: ID!) { + swarm { + users { + byId(id: $id) { + canonicalName + createdAt + enabled + id + name + systemIdentifier + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUserNameByNodeId.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUserNameByNodeId.graphql new file mode 100644 index 0000000000..5565345e8a --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUserNameByNodeId.graphql @@ -0,0 +1,8 @@ +query GetUserNameByNodeId($id: ID!) { + node(id: $id) { + ... on UserName { + id + name + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ListUserGroups.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ListUserGroups.graphql new file mode 100644 index 0000000000..08bc93b4fd --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ListUserGroups.graphql @@ -0,0 +1,14 @@ +query ListUserGroups { + swarm { + users { + groups { + queryableGroups { + totalCount + nodes { + id + } + } + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ListUsers.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ListUsers.graphql new file mode 100644 index 0000000000..1553df9fc0 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ListUsers.graphql @@ -0,0 +1,12 @@ +query ListUsers { + swarm { + users { + queryableUsers { + nodes { + id + } + totalCount + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/PageUserIds.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/PageUserIds.graphql new file mode 100644 index 0000000000..8db5d7db4a --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/PageUserIds.graphql @@ -0,0 +1,16 @@ +query PageUserIds($first: Int, $after: String) { + swarm { + users { + queryableUsers(first: $first, after: $after) { + pageInfo { + endCursor + hasNextPage + } + totalCount + nodes { + id + } + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ReadCurrentUser.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ReadCurrentUser.graphql new file mode 100644 index 0000000000..dea2849d19 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ReadCurrentUser.graphql @@ -0,0 +1,51 @@ +query ReadCurrentUser { + swarm { + users { + current { + canonicalName + createdAt + enabled + id + name + systemIdentifier + group { + id + name + } + oAuthConnections { + externalUserId + provider + } + effectivePermissionSet { + administrationRights { + canChangeVersion + canDownloadLogs + canEditOwnOAuthConnections + canEditOwnPassword + canReadUsers + canRestartHost + canUploadVersion + canWriteUsers + } + instanceManagerRights { + canCreate + canDelete + canGrantPermissions + canList + canRead + canRelocate + canRename + canSetAutoUpdate + canSetChatBotLimit + canSetConfiguration + canSetOnline + } + } + createdBy { + id + name + } + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformationQuery.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformationQuery.graphql deleted file mode 100644 index 6f130e4d24..0000000000 --- a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformationQuery.graphql +++ /dev/null @@ -1,34 +0,0 @@ -query ServerInformationQuery { - swarm { - metadata { - apiVersion - dmApiVersion - updateInProgress - version - } - localServer { - information { - instanceLimit - minimumPasswordLength - userGroupLimit - userLimit - validInstancePaths - windowsHost - oAuthProviderInfos { - key - value { - clientId - redirectUri - serverUrl - } - } - } - } - servers { - address - controller - identifier - publicAddress - } - } -} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerVersion.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerVersion.graphql new file mode 100644 index 0000000000..45dbf4ebbc --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerVersion.graphql @@ -0,0 +1,11 @@ +query ServerVersion { + swarm { + currentNode { + gateway { + information { + version + } + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/UnauthenticatedServerInformation.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/UnauthenticatedServerInformation.graphql new file mode 100644 index 0000000000..be535b84a0 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/UnauthenticatedServerInformation.graphql @@ -0,0 +1,11 @@ +query UnauthenticatedServerInformation { + swarm { + currentNode { + gateway { + information { + majorGraphQLApiVersion + } + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SessionInvalidation.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SessionInvalidation.graphql new file mode 100644 index 0000000000..e7e5baf3de --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SessionInvalidation.graphql @@ -0,0 +1,3 @@ +subscription SessionInvalidation { + sessionInvalidated +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SubscribeUsers.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SubscribeUsers.graphql new file mode 100644 index 0000000000..fe437ef62d --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SubscribeUsers.graphql @@ -0,0 +1,5 @@ +subscription SubscribeUsers { + userUpdated { + id + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs index b359139b69..0f46608df1 100644 --- a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs +++ b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs @@ -1,11 +1,34 @@ using System; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Headers; +using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +using StrawberryShake; + +using Tgstation.Server.Api; + namespace Tgstation.Server.Client.GraphQL { /// class GraphQLServerClient : IGraphQLServerClient { + /// + /// If the was initially authenticated. + /// + [MemberNotNullWhen(true, nameof(setAuthenticationHeader))] + [MemberNotNullWhen(true, nameof(bearerCredentialsTask))] + bool Authenticated => basicCredentialsHeader != null; + + /// + /// If the supports reauthentication. + /// + [MemberNotNullWhen(true, nameof(bearerCredentialsHeaderTaskLock))] + [MemberNotNullWhen(true, nameof(basicCredentialsHeader))] + bool CanReauthenticate => basicCredentialsHeader != null; + /// /// The for the . /// @@ -16,27 +39,252 @@ namespace Tgstation.Server.Client.GraphQL /// readonly IAsyncDisposable serviceProvider; + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// The which sets the for HTTP request in the current async context. + /// + readonly Action? setAuthenticationHeader; + + /// + /// The containing the authenticated user's password credentials. + /// + readonly AuthenticationHeaderValue? basicCredentialsHeader; + + /// + /// used to synchronize access to . + /// + readonly object? bearerCredentialsHeaderTaskLock; + + /// + /// A resulting in a containing the current for the and the it expires. + /// + Task<(AuthenticationHeaderValue Header, DateTime Exp)?>? bearerCredentialsTask; + + /// + /// Throws an for a login error that previously occured outside of the current call context. + /// + /// Always thrown. + [DoesNotReturn] + static void ThrowOtherCallerFailedAuthException() + => throw new AuthenticationException("Another caller failed to authenticate!"); + /// /// Initializes a new instance of the class. /// /// The value of . /// The value of . + /// The value of . public GraphQLServerClient( IGraphQLClient graphQLClient, - IAsyncDisposable serviceProvider) + IAsyncDisposable serviceProvider, + ILogger logger) { this.graphQLClient = graphQLClient ?? throw new ArgumentNullException(nameof(graphQLClient)); this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The containing the initial JWT to use. + protected GraphQLServerClient( + IGraphQLClient graphQLClient, + IAsyncDisposable serviceProvider, + ILogger logger, + Action setAuthenticationHeader, + AuthenticationHeaderValue? basicCredentialsHeader, + IOperationResult loginResult) + : this(graphQLClient, serviceProvider, logger) + { + this.setAuthenticationHeader = setAuthenticationHeader ?? throw new ArgumentNullException(nameof(setAuthenticationHeader)); + ArgumentNullException.ThrowIfNull(loginResult); + this.basicCredentialsHeader = basicCredentialsHeader; + + var task = CreateCredentialsTuple(loginResult); + if (!task.IsCompleted) + throw new InvalidOperationException($"Expected {nameof(CreateCredentialsTuple)} to not await in constructor!"); + + bearerCredentialsTask = Task.FromResult<(AuthenticationHeaderValue Header, DateTime Exp)?>(task.Result); + + if (Authenticated) + bearerCredentialsHeaderTaskLock = new object(); } /// - public ValueTask DisposeAsync() => serviceProvider.DisposeAsync(); + public virtual ValueTask DisposeAsync() => serviceProvider.DisposeAsync(); /// - public virtual ValueTask RunQuery(Func queryExector) + public ValueTask> RunOperationAsync(Func>> operationExecutor, CancellationToken cancellationToken) + where TResultData : class { - ArgumentNullException.ThrowIfNull(queryExector); - return queryExector(graphQLClient); + ArgumentNullException.ThrowIfNull(operationExecutor); + return WrapAuthentication(operationExecutor, cancellationToken); + } + + /// + public ValueTask> RunOperation(Func>> operationExecutor, CancellationToken cancellationToken) + where TResultData : class + { + ArgumentNullException.ThrowIfNull(operationExecutor); + return WrapAuthentication(async localClient => await operationExecutor(localClient), cancellationToken); + } + + /// + public async ValueTask Subscribe(Func>> operationExecutor, IObserver> observer, CancellationToken cancellationToken) + where TResultData : class + { + ArgumentNullException.ThrowIfNull(operationExecutor); + ArgumentNullException.ThrowIfNull(observer); + + var observable = operationExecutor(graphQLClient); + + if (Authenticated) + { + var tuple = await bearerCredentialsTask.ConfigureAwait(false); + if (!tuple.HasValue) + ThrowOtherCallerFailedAuthException(); + + var (currentAuthHeader, expires) = tuple.Value; + if (expires <= DateTimeOffset.UtcNow) + currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false); + + setAuthenticationHeader(currentAuthHeader); + } + + // maybe make this handle reauthentication one day + // but would need to check if lost auth results in complete events being sent + // if so, it can't be done + return observable.Subscribe(observer); + } + + /// + /// Create a from a given token. + /// + /// The . + /// A new . + protected virtual ValueTask CreateUpdatedAuthenticationHeader(string bearer) + => ValueTask.FromResult( + new AuthenticationHeaderValue( + ApiHeaders.BearerAuthenticationScheme, + bearer)); + + /// + /// Executes a given , potentially accounting for authentication issues. + /// + /// The of the 's . + /// A which executes a single query on a given and returns a resulting in the . + /// The for the operation. + /// A resulting in the . + async ValueTask> WrapAuthentication(Func>> operationExecutor, CancellationToken cancellationToken) + where TResultData : class + { + if (!Authenticated) + return await operationExecutor(graphQLClient).ConfigureAwait(false); + + var tuple = await bearerCredentialsTask.ConfigureAwait(false); + if (!tuple.HasValue) + ThrowOtherCallerFailedAuthException(); + + var (currentAuthHeader, expires) = tuple.Value; + if (expires <= DateTimeOffset.UtcNow) + currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false); + + setAuthenticationHeader(currentAuthHeader); + + var operationResult = await operationExecutor(graphQLClient); + + if (operationResult.IsAuthenticationError()) + { + currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false); + setAuthenticationHeader(currentAuthHeader); + return await operationExecutor(graphQLClient); + } + + return operationResult; + } + + /// + /// Attempt to reauthenticate. + /// + /// The current for the bearer token. + /// The for the operation. + /// A resulting in the updated to use. + async ValueTask Reauthenticate(AuthenticationHeaderValue currentToken, CancellationToken cancellationToken) + { + if (!CanReauthenticate) + throw new AuthenticationException("Authentication expired or invalid and cannot re-authenticate."); + + TaskCompletionSource<(AuthenticationHeaderValue Header, DateTime Exp)?>? tcs = null; + do + { + var bearerCredentialsTaskLocal = bearerCredentialsTask; + if (!bearerCredentialsTaskLocal!.IsCompleted) + { + var currentTuple = await bearerCredentialsTaskLocal.ConfigureAwait(false); + if (!currentTuple.HasValue) + ThrowOtherCallerFailedAuthException(); + + return currentTuple.Value.Header; + } + + lock (bearerCredentialsHeaderTaskLock!) + { + if (bearerCredentialsTask == bearerCredentialsTaskLocal) + { + var result = bearerCredentialsTaskLocal.Result; + if (result?.Header != currentToken) + { + if (!result.HasValue) + ThrowOtherCallerFailedAuthException(); + + return result.Value.Header; + } + + tcs = new TaskCompletionSource<(AuthenticationHeaderValue, DateTime)?>(); + bearerCredentialsTask = tcs.Task; + } + } + } + while (tcs == null); + + setAuthenticationHeader!(basicCredentialsHeader!); + var loginResult = await graphQLClient.Login.ExecuteAsync(cancellationToken).ConfigureAwait(false); + try + { + var tuple = await CreateCredentialsTuple(loginResult).ConfigureAwait(false); + tcs.SetResult(tuple); + return tuple.Header; + } + catch (AuthenticationException) + { + tcs.SetResult(null); + throw; + } + } + + /// + /// Attempt to create the for . + /// + /// The to process. + /// A resulting in a new credentials . + /// Thrown if the errored. + async ValueTask<(AuthenticationHeaderValue Header, DateTime Exp)> CreateCredentialsTuple(IOperationResult loginResult) + { + var bearer = loginResult.EnsureSuccess(logger); + + var header = await CreateUpdatedAuthenticationHeader(bearer.EncodedToken); + + return (Header: header, Exp: bearer.ValidTo); } } } diff --git a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClientFactory.cs b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClientFactory.cs index bbcd7f2f6e..50b602685d 100644 --- a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClientFactory.cs +++ b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClientFactory.cs @@ -1,11 +1,18 @@ using System; +using System.Net.Http.Headers; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using StrawberryShake; using Tgstation.Server.Api; +using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Client.GraphQL.Serializers; +using Tgstation.Server.Common.Extensions; namespace Tgstation.Server.Client.GraphQL { @@ -17,6 +24,45 @@ namespace Tgstation.Server.Client.GraphQL /// readonly IRestServerClientFactory restClientFactory; + /// + /// Sets up a for providing the . + /// + /// The of the target tgstation-server. + /// If the should be configured. + /// The override for the . + /// The , if any. + /// A new . + static ServiceProvider SetupServiceProvider(Uri host, bool addAuthorizationHandler, AuthenticationHeaderValue? headerOverride = null, OAuthProvider? oAuthProvider = null) + { + var serviceCollection = new ServiceCollection(); + + var clientBuilder = serviceCollection + .AddGraphQLClient(); + var graphQLEndpoint = new Uri(host, Routes.GraphQL); + + clientBuilder.ConfigureHttpClient( + client => + { + client.BaseAddress = graphQLEndpoint; + client.DefaultRequestHeaders.Add(ApiHeaders.ApiVersionHeader, $"Tgstation.Server.Api/{ApiHeaders.Version.Semver()}"); + if (oAuthProvider.HasValue) + { + client.DefaultRequestHeaders.Add(ApiHeaders.OAuthProviderHeader, oAuthProvider.ToString()); + } + }, + clientBuilder => + { + if (addAuthorizationHandler) + clientBuilder.AddHttpMessageHandler(() => new AuthorizationMessageHandler(headerOverride)); + }); + + serviceCollection.AddSerializer(); + serviceCollection.AddSerializer(); + serviceCollection.AddSerializer(); + + return serviceCollection.BuildServiceProvider(); + } + /// /// Initializes a new instance of the class. /// @@ -29,39 +75,138 @@ namespace Tgstation.Server.Client.GraphQL /// public ValueTask CreateFromLogin(Uri host, string username, string password, bool attemptLoginRefresh = true, CancellationToken cancellationToken = default) { - throw new NotImplementedException(); + var basicCredentials = new AuthenticationHeaderValue( + ApiHeaders.BasicAuthenticationScheme, + Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{username}:{password}"))); + + return CreateWithAuthCall( + host, + basicCredentials, + null, + attemptLoginRefresh, + cancellationToken); } /// public ValueTask CreateFromOAuth(Uri host, string oAuthCode, OAuthProvider oAuthProvider, CancellationToken cancellationToken = default) { - throw new NotImplementedException(); + var oAuthCredentials = new AuthenticationHeaderValue( + ApiHeaders.OAuthAuthenticationScheme, + oAuthCode); + + return CreateWithAuthCall( + host, + oAuthCredentials, + oAuthProvider, + false, + cancellationToken); } /// public IAuthenticatedGraphQLServerClient CreateFromToken(Uri host, string token) { - throw new NotImplementedException(); + var authenticationHeader = new AuthenticationHeaderValue( + ApiHeaders.BearerAuthenticationScheme, + token); + + var serviceProvider = SetupServiceProvider( + host, + true, + authenticationHeader); + + return new AuthenticatedGraphQLServerClient( + serviceProvider.GetRequiredService(), + serviceProvider, + serviceProvider.GetRequiredService>(), + CreateAuthenticatedTransferClient(host, token)); } /// public IGraphQLServerClient CreateUnauthenticated(Uri host) { - var serviceCollection = new ServiceCollection(); - - var clientBuilder = serviceCollection - .AddGraphQLClient(); - var graphQLEndpoint = new Uri(host, Routes.GraphQL); - clientBuilder.ConfigureHttpClient(client => client.BaseAddress = graphQLEndpoint); - - serviceCollection.AddSerializer(); - serviceCollection.AddSerializer(); - - var serviceProvider = serviceCollection.BuildServiceProvider(); + var serviceProvider = SetupServiceProvider(host, false); return new GraphQLServerClient( serviceProvider.GetRequiredService(), - serviceProvider); + serviceProvider, + serviceProvider.GetRequiredService>()); + } + + /// + /// Create an from a remote login call. + /// + /// The URL to access TGS. + /// The initial to use to login. + /// The , if any. + /// If the client should attempt to renew its sessions with the . + /// Optional for the operation. + /// A resulting in a new . + /// Thrown when authentication fails. + async ValueTask CreateWithAuthCall( + Uri host, + AuthenticationHeaderValue initialCredentials, + OAuthProvider? oAuthProvider, + bool attemptLoginRefresh, + CancellationToken cancellationToken) + { + var serviceProvider = SetupServiceProvider( + host, + true, + oAuthProvider: oAuthProvider); + try + { + var client = serviceProvider.GetRequiredService(); + + IOperationResult result; + + var previousAuthHeader = AuthorizationMessageHandler.Header.Value; + AuthorizationMessageHandler.Header.Value = initialCredentials; + try + { + result = await client.Login.ExecuteAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + AuthorizationMessageHandler.Header.Value = previousAuthHeader; + } + + var serverClient = new AuthenticatedGraphQLServerClient( + client, + serviceProvider, + serviceProvider.GetRequiredService>(), + newHeader => AuthorizationMessageHandler.Header.Value = newHeader, + attemptLoginRefresh ? initialCredentials : null, + result, + bearer => CreateAuthenticatedTransferClient(host, bearer)); + + await Task.Yield(); + + return serverClient; + } + catch + { + await serviceProvider.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + /// + /// Create a for a given and token. + /// + /// The URL to access TGS. + /// The bearer token to access the API with. + /// A new . + IRestServerClient CreateAuthenticatedTransferClient(Uri host, string bearer) + { + var restClient = restClientFactory.CreateFromToken( + host, + new TokenResponse + { + Bearer = bearer, + }); + + return restClient; } } } diff --git a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs index 8c6d2ff2fe..bddfeae618 100644 --- a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs +++ b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs @@ -1,6 +1,9 @@ using System; +using System.Threading; using System.Threading.Tasks; +using StrawberryShake; + namespace Tgstation.Server.Client.GraphQL { /// @@ -9,10 +12,36 @@ namespace Tgstation.Server.Client.GraphQL public interface IGraphQLServerClient : IAsyncDisposable { /// - /// Runs a given . It may be invoked multiple times depending on the behavior of the . + /// Runs a given . It may be invoked multiple times depending on the behavior of the if reauthentication is required. /// - /// A which executes a single query on a given and returns a representing the running operation. - /// A representing the running operation. - ValueTask RunQuery(Func queryExector); + /// The of the 's . + /// A which executes a single query on a given and returns a resulting in the . + /// The for the operation. + /// A resulting in the . + /// Thrown when automatic reauthentication fails. + ValueTask> RunOperationAsync(Func>> operationExecutor, CancellationToken cancellationToken) + where TResultData : class; + + /// + /// Runs a given . It may be invoked multiple times depending on the behavior of the if reauthentication is required. + /// + /// The of the 's . + /// A which executes a single query on a given and returns a resulting in the . + /// The for the operation. + /// A resulting in the . + /// Thrown when automatic reauthentication fails. + ValueTask> RunOperation(Func>> operationExecutor, CancellationToken cancellationToken) + where TResultData : class; + + /// + /// Subcribes to the GraphQL subscription indicated by . + /// + /// The of the 's . + /// A which initiates a single subscription on a given and returns a resulting in the . + /// The for s. + /// The for the operation. + /// A resulting in the representing the lifetime of the subscription. + ValueTask Subscribe(Func>> operationExecutor, IObserver> observer, CancellationToken cancellationToken) + where TResultData : class; } } diff --git a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClientFactory.cs b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClientFactory.cs index 06faa66adb..5ace7c2f8a 100644 --- a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClientFactory.cs +++ b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClientFactory.cs @@ -2,8 +2,6 @@ using System.Threading; using System.Threading.Tasks; -using Tgstation.Server.Api.Models.Response; - namespace Tgstation.Server.Client.GraphQL { /// @@ -24,9 +22,10 @@ namespace Tgstation.Server.Client.GraphQL /// The URL to access TGS. /// The username to for the . /// The password for the . - /// Attempt to refresh the received when it expires or becomes invalid. and will be stored in memory if this is . + /// Attempt to refresh the received bearer token when it expires or becomes invalid. and will be stored in memory if this is . /// Optional for the operation. /// A resulting in a new . + /// Thrown when authentication fails. ValueTask CreateFromLogin( Uri host, string username, @@ -42,6 +41,7 @@ namespace Tgstation.Server.Client.GraphQL /// The . /// Optional for the operation. /// A resulting in a new . + /// Thrown when authentication fails. ValueTask CreateFromOAuth( Uri host, string oAuthCode, @@ -52,7 +52,7 @@ namespace Tgstation.Server.Client.GraphQL /// Create a . /// /// The URL to access TGS. - /// The to access the API with. + /// The bearer token to access the API with. /// A new . IAuthenticatedGraphQLServerClient CreateFromToken( Uri host, diff --git a/src/Tgstation.Server.Client.GraphQL/LoginResultExtensions.cs b/src/Tgstation.Server.Client.GraphQL/LoginResultExtensions.cs new file mode 100644 index 0000000000..cdfaab9d3b --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/LoginResultExtensions.cs @@ -0,0 +1,73 @@ +using System; +using System.Linq; + +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.JsonWebTokens; +using StrawberryShake; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + /// Extensions for . + /// + static class LoginResultExtensions + { + /// + /// Check a given for errors. + /// + /// The containing the . + /// The to write to. + /// The from the successful . + /// Thrown when the is errored. + public static JsonWebToken EnsureSuccess(this IOperationResult loginResult, ILogger logger) + { + ArgumentNullException.ThrowIfNull(loginResult); + + try + { + loginResult.EnsureNoErrors(); + } + catch (GraphQLClientException ex) + { + throw new AuthenticationException("Login attempt errored at the GraphQL level!", ex); + } + + var data = loginResult.Data!.Login; + var errors = data.Errors; + if (errors != null) + { + foreach (var error in errors) + { + if (error is ILogin_Login_Errors_ErrorMessageError errorMessageError) + logger.LogError( + "Authentication error ({code}): {message}{additionalData}", + errorMessageError.ErrorCode?.ToString() ?? "No Code", + errorMessageError.Message, + errorMessageError.AdditionalData != null + ? $"{Environment.NewLine}{errorMessageError.AdditionalData}" + : String.Empty); + else + logger.LogError( + "Unknown authentication error: {error}", + error); + } + } + + if (data.LoginResult == null) + { + if (errors != null) + { + var errorMessage = errors.OfType().FirstOrDefault(); + if (errorMessage != null) + throw new AuthenticationException(errorMessage); + + throw new AuthenticationException($"Null bearer field and {errors.Count} non-ErrorMessage errors:{(errors.Count > 0 ? $"{Environment.NewLine}\t- {String.Join($"{Environment.NewLine}\t- ", errors)}" : String.Empty)}"); + } + + throw new AuthenticationException($"Null bearer and error fields!"); + } + + return data.LoginResult.Bearer; + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/OperationResultExtensions.cs b/src/Tgstation.Server.Client.GraphQL/OperationResultExtensions.cs new file mode 100644 index 0000000000..0488db827d --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/OperationResultExtensions.cs @@ -0,0 +1,30 @@ +using System; +using System.Linq; + +using StrawberryShake; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + /// Extension methods for the interface. + /// + public static class OperationResultExtensions + { + /// + /// Checks if a given errored out with authentication errors. + /// + /// The . + /// if errored due to authentication issues, otherwise. + public static bool IsAuthenticationError(this IOperationResult operationResult) + { + ArgumentNullException.ThrowIfNull(operationResult); + + return operationResult.Errors.Any( + error => error.Extensions?.TryGetValue( + "code", + out object? codeExtension) == true + && codeExtension is string codeExtensionString + && codeExtensionString == "AUTH_NOT_AUTHENTICATED"); + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/Serializers/JwtSerializer.cs b/src/Tgstation.Server.Client.GraphQL/Serializers/JwtSerializer.cs new file mode 100644 index 0000000000..41a0a960a2 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/Serializers/JwtSerializer.cs @@ -0,0 +1,35 @@ +using System; + +using Microsoft.IdentityModel.JsonWebTokens; + +using StrawberryShake.Serialization; + +#pragma warning disable CA1812 // not detecting usage via annotation in schema.extensions.graphql + +namespace Tgstation.Server.Client.GraphQL.Serializers +{ + /// + /// for s. + /// + sealed class JwtSerializer : ScalarSerializer + { + /// + /// Initializes a new instance of the class. + /// + public JwtSerializer() + : base("Jwt") + { + } + + /// + public override JsonWebToken Parse(string serializedValue) + => new(serializedValue ?? throw new ArgumentNullException(nameof(serializedValue))); + + /// + protected override string Format(JsonWebToken runtimeValue) + { + ArgumentNullException.ThrowIfNull(runtimeValue); + return runtimeValue.EncodedToken; + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/Serializers/SemverSerializer.cs b/src/Tgstation.Server.Client.GraphQL/Serializers/SemverSerializer.cs index cd7c6f378e..35fea8f509 100644 --- a/src/Tgstation.Server.Client.GraphQL/Serializers/SemverSerializer.cs +++ b/src/Tgstation.Server.Client.GraphQL/Serializers/SemverSerializer.cs @@ -4,12 +4,12 @@ using StrawberryShake.Serialization; using Tgstation.Server.Common.Extensions; -#pragma warning disable CA1812 // not detecting service provider usage +#pragma warning disable CA1812 // not detecting usage via annotation in schema.extensions.graphql namespace Tgstation.Server.Client.GraphQL.Serializers { /// - /// for s. + /// for s. /// sealed class SemverSerializer : ScalarSerializer { diff --git a/src/Tgstation.Server.Client.GraphQL/Serializers/UnsignedIntSerializer.cs b/src/Tgstation.Server.Client.GraphQL/Serializers/UnsignedIntSerializer.cs index 8656438b11..fdc610d07c 100644 --- a/src/Tgstation.Server.Client.GraphQL/Serializers/UnsignedIntSerializer.cs +++ b/src/Tgstation.Server.Client.GraphQL/Serializers/UnsignedIntSerializer.cs @@ -2,7 +2,7 @@ using StrawberryShake.Serialization; -#pragma warning disable CA1812 // not detecting service provider usage +#pragma warning disable CA1812 // not detecting usage via annotation in schema.extensions.graphql namespace Tgstation.Server.Client.GraphQL.Serializers { diff --git a/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj b/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj index 3299c2b4d9..780cbbcd7c 100644 --- a/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj +++ b/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj @@ -3,12 +3,14 @@ $(TgsFrameworkVersion) - $(TgsApiVersion) + + enable - + + diff --git a/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql b/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql index 023788fd33..8991e1fa70 100644 --- a/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql +++ b/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql @@ -15,3 +15,4 @@ extend schema @key(fields: "id") extend scalar UnsignedInt @serializationType(name: "global::System.UInt32") @runtimeType(name: "global::System.UInt32") extend scalar Semver @serializationType(name: "global::System.String") @runtimeType(name: "global::System.Version") +extend scalar Jwt @serializationType(name: "global::System.String") @runtimeType(name: "global::Microsoft.IdentityModel.JsonWebTokens.JsonWebToken") diff --git a/src/Tgstation.Server.Client/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs index e9824a433d..0a7214e3cd 100644 --- a/src/Tgstation.Server.Client/ApiClient.cs +++ b/src/Tgstation.Server.Client/ApiClient.cs @@ -194,7 +194,7 @@ namespace Tgstation.Server.Client disposed = true; - localHubConnections = hubConnections.ToList(); + localHubConnections = [.. hubConnections]; hubConnections.Clear(); } @@ -405,51 +405,56 @@ namespace Tgstation.Server.Client if (loggingConfigureAction != null) hubConnectionBuilder.ConfigureLogging(loggingConfigureAction); - hubConnection = hubConnectionBuilder.Build(); - try + async ValueTask AttemptConnect() { - hubConnection.Closed += async (error) => + hubConnection = hubConnectionBuilder.Build(); + try { - if (error is HttpRequestException httpRequestException) + hubConnection.Closed += async (error) => { - // .StatusCode isn't in netstandard but fuck the police - var property = error.GetType().GetProperty("StatusCode"); - if (property != null) + if (error is HttpRequestException httpRequestException) { - var statusCode = (HttpStatusCode?)property.GetValue(error); - if (statusCode == HttpStatusCode.Unauthorized - && !await RefreshToken(CancellationToken.None)) - _ = hubConnection!.StopAsync(); + // .StatusCode isn't in netstandard but fuck the police + var property = error.GetType().GetProperty("StatusCode"); + if (property != null) + { + var statusCode = (HttpStatusCode?)property.GetValue(error); + if (statusCode == HttpStatusCode.Unauthorized + && !await RefreshToken(CancellationToken.None)) + _ = hubConnection!.StopAsync(); + } } + }; + + hubConnection.ProxyOn(hubImplementation); + + Task startTask; + lock (hubConnections) + { + if (disposed) + throw new ObjectDisposedException(nameof(ApiClient)); + + hubConnections.Add(hubConnection); + startTask = hubConnection.StartAsync(cancellationToken); } - }; - hubConnection.ProxyOn(hubImplementation); + await startTask; - Task startTask; - lock (hubConnections) - { - if (disposed) - throw new ObjectDisposedException(nameof(ApiClient)); - - hubConnections.Add(hubConnection); - startTask = hubConnection.StartAsync(cancellationToken); + return hubConnection; } + catch + { + bool needsDispose; + lock (hubConnections) + needsDispose = hubConnections.Remove(hubConnection); - await startTask; - - return hubConnection; + if (needsDispose) + await hubConnection.DisposeAsync(); + throw; + } } - catch - { - bool needsDispose; - lock (hubConnections) - needsDispose = hubConnections.Remove(hubConnection); - if (needsDispose) - await hubConnection.DisposeAsync(); - throw; - } + return await WrapHubInitialConnectAuthRefresh(AttemptConnect, cancellationToken); } /// @@ -571,6 +576,35 @@ namespace Tgstation.Server.Client } #pragma warning restore CA1506 + /// + /// Wrap a hub connection attempt via a with proper token refreshing. + /// + /// The . + /// The for the operation. + /// A resulting in the connected . + async ValueTask WrapHubInitialConnectAuthRefresh(Func> connectFunc, CancellationToken cancellationToken) + { + try + { + return await connectFunc(); + } + catch (HttpRequestException ex) + { + // status code is not in netstandard + var propertyInfo = ex.GetType().GetProperty("StatusCode"); + if (propertyInfo != null) + { + var statusCode = (HttpStatusCode)propertyInfo.GetValue(ex); + if (statusCode != HttpStatusCode.Unauthorized) + throw; + } + + await RefreshToken(cancellationToken); + + return await connectFunc(); + } + } + /// /// Main request method. /// diff --git a/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj b/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj index 3bca360847..5046c091a8 100644 --- a/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj +++ b/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj @@ -11,9 +11,9 @@ - + - + diff --git a/src/Tgstation.Server.Host.Console/Tgstation.Server.Host.Console.csproj b/src/Tgstation.Server.Host.Console/Tgstation.Server.Host.Console.csproj index a405553d55..ff23533a16 100644 --- a/src/Tgstation.Server.Host.Console/Tgstation.Server.Host.Console.csproj +++ b/src/Tgstation.Server.Host.Console/Tgstation.Server.Host.Console.csproj @@ -12,9 +12,9 @@ - + - + diff --git a/src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj b/src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj index 5dd0bd9553..417e03092c 100644 --- a/src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj +++ b/src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj @@ -20,15 +20,15 @@ - + - + - + - + diff --git a/src/Tgstation.Server.Host.Watchdog/Tgstation.Server.Host.Watchdog.csproj b/src/Tgstation.Server.Host.Watchdog/Tgstation.Server.Host.Watchdog.csproj index 04f187c08e..dad6a14454 100644 --- a/src/Tgstation.Server.Host.Watchdog/Tgstation.Server.Host.Watchdog.csproj +++ b/src/Tgstation.Server.Host.Watchdog/Tgstation.Server.Host.Watchdog.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/Tgstation.Server.Host/.config/dotnet-tools.json b/src/Tgstation.Server.Host/.config/dotnet-tools.json index a3847dcdfb..6b7095305f 100644 --- a/src/Tgstation.Server.Host/.config/dotnet-tools.json +++ b/src/Tgstation.Server.Host/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "8.0.8", + "version": "8.0.10", "commands": [ "dotnet-ef" ] diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs new file mode 100644 index 0000000000..f0e39151a3 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs @@ -0,0 +1,131 @@ +using System; +using System.Globalization; + +using Microsoft.Extensions.Logging; + +using Octokit; + +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Base implementation of . + /// + abstract class AuthorityBase : IAuthority + { + /// + /// Gets the for the . + /// + protected IAuthenticationContext AuthenticationContext { get; } + + /// + /// Gets the for the . + /// + protected IDatabaseContext DatabaseContext { get; } + + /// + /// Gets the for the . + /// + protected ILogger Logger { get; } + + /// + /// Generates a type . + /// + /// The of the . + /// The . + /// A new, errored . + protected static AuthorityResponse BadRequest(ErrorCode errorCode) + => new( + new ErrorMessageResponse(errorCode), + HttpFailureResponse.BadRequest); + + /// + /// Generates a type . + /// + /// The of the . + /// A new, errored . + protected static AuthorityResponse Unauthorized() + => new( + new ErrorMessageResponse(), + HttpFailureResponse.Unauthorized); + + /// + /// Generates a type . + /// + /// The of the . + /// A new, errored . + protected static AuthorityResponse Gone() + => new( + new ErrorMessageResponse(), + HttpFailureResponse.Gone); + + /// + /// Generates a type . + /// + /// The of the . + /// A new, errored . + protected static AuthorityResponse Forbid() + => new( + new ErrorMessageResponse(), + HttpFailureResponse.Forbidden); + + /// + /// Generates a type . + /// + /// The of the . + /// A new, errored . + protected static AuthorityResponse NotFound() + => new( + new ErrorMessageResponse(), + HttpFailureResponse.NotFound); + + /// + /// Generates a type . + /// + /// The of the . + /// The . + /// A new, errored . + protected static AuthorityResponse Conflict(ErrorCode errorCode) + => new( + new ErrorMessageResponse(errorCode), + HttpFailureResponse.Conflict); + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + /// The value of . + protected AuthorityBase( + IAuthenticationContext authenticationContext, + IDatabaseContext databaseContext, + ILogger logger) + { + AuthenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); + DatabaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Generates a type . + /// + /// The of the . + /// The thrown . + /// A new, errored . + protected AuthorityResponse RateLimit(RateLimitExceededException rateLimitException) + { + Logger.LogWarning(rateLimitException, "Exceeded GitHub rate limit!"); + var secondsString = Math.Ceiling(rateLimitException.GetRetryAfterTimeSpan().TotalSeconds).ToString(CultureInfo.InvariantCulture); + return new( + new ErrorMessageResponse(ErrorCode.GitHubApiRateLimit) + { + AdditionalData = $"Retry-After: {secondsString}s", + }, + HttpFailureResponse.RateLimited); + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs new file mode 100644 index 0000000000..f04293aecc --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs @@ -0,0 +1,48 @@ +using System; +using System.Linq; + +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + abstract class AuthorityInvokerBase : IAuthorityInvoker + where TAuthority : IAuthority + { + /// + /// The being invoked. + /// + protected TAuthority Authority { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public AuthorityInvokerBase(TAuthority authority) + { + Authority = authority ?? throw new ArgumentNullException(nameof(authority)); + } + + /// + IQueryable IAuthorityInvoker.InvokeQueryable(Func> authorityInvoker) + { + ArgumentNullException.ThrowIfNull(authorityInvoker); + return authorityInvoker(Authority); + } + + /// + IQueryable IAuthorityInvoker.InvokeTransformableQueryable(Func> authorityInvoker) + { + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var queryable = authorityInvoker(Authority); + + if (typeof(EntityId).IsAssignableFrom(typeof(TResult))) + queryable = queryable.OrderBy(item => ((EntityId)(object)item).Id!.Value); // order by ID to fix an EFCore warning + + var expression = new TTransformer().Expression; + return queryable + .Select(expression); + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse.cs new file mode 100644 index 0000000000..61b388dc83 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse.cs @@ -0,0 +1,48 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +using Tgstation.Server.Api.Models.Response; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Represents a response from an authority. + /// + public class AuthorityResponse + { + /// + /// Checks if the was successful. + /// + [MemberNotNullWhen(false, nameof(ErrorMessage))] + [MemberNotNullWhen(false, nameof(FailureResponse))] + public virtual bool Success => ErrorMessage == null; + + /// + /// Gets the associated . Must only be used if is . + /// + public ErrorMessageResponse? ErrorMessage { get; } + + /// + /// The . + /// + public HttpFailureResponse? FailureResponse { get; } + + /// + /// Initializes a new instance of the class. + /// + public AuthorityResponse() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public AuthorityResponse(ErrorMessageResponse errorMessage, HttpFailureResponse failureResponse) + { + ErrorMessage = errorMessage ?? throw new ArgumentNullException(nameof(errorMessage)); + FailureResponse = failureResponse; + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse{TResult}.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse{TResult}.cs new file mode 100644 index 0000000000..9c2cfd295c --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse{TResult}.cs @@ -0,0 +1,65 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +using Microsoft.AspNetCore.Mvc; +using Tgstation.Server.Api.Models.Response; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// An with a . + /// + /// The of success response. + public sealed class AuthorityResponse : AuthorityResponse + { + /// + [MemberNotNullWhen(true, nameof(IsNoContent))] + public override bool Success => base.Success; + + /// + /// Checks if a the is a no content result. Only set on . + /// + [MemberNotNullWhen(false, nameof(Result))] + [MemberNotNullWhen(false, nameof(Result))] + public bool? IsNoContent => Success ? Result == null : null; + + /// + /// The success . + /// + public TResult? Result { get; } + + /// + /// The for generating the s. + /// + public HttpSuccessResponse? SuccessResponse { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + public AuthorityResponse(ErrorMessageResponse errorMessage, HttpFailureResponse httpResponse) + : base(errorMessage, httpResponse) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public AuthorityResponse(TResult result, HttpSuccessResponse httpResponse = HttpSuccessResponse.Ok) + { + Result = result ?? throw new ArgumentNullException(nameof(result)); + SuccessResponse = httpResponse; + } + + /// + /// Initializes a new instance of the class. + /// + /// This generates an HTTP 204 response. + public AuthorityResponse() + { + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs new file mode 100644 index 0000000000..a25a04e278 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading.Tasks; + +using Tgstation.Server.Host.GraphQL; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + sealed class GraphQLAuthorityInvoker : AuthorityInvokerBase, IGraphQLAuthorityInvoker + where TAuthority : IAuthority + { + /// + /// Throws a for errored s. + /// + /// The potentially errored . + /// If an error should be raised for and failures. + static void ThrowGraphQLErrorIfNecessary(AuthorityResponse authorityResponse, bool errorOnMissing) + { + if (authorityResponse.Success + || ((authorityResponse.FailureResponse.Value == HttpFailureResponse.NotFound + || authorityResponse.FailureResponse.Value == HttpFailureResponse.Gone) && !errorOnMissing)) + return; + + var fallbackString = authorityResponse.FailureResponse.ToString()!; + throw new ErrorMessageException(authorityResponse.ErrorMessage, fallbackString); + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + public GraphQLAuthorityInvoker(TAuthority authority) + : base(authority) + { + } + + /// + async ValueTask IGraphQLAuthorityInvoker.Invoke(Func> authorityInvoker) + { + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var authorityResponse = await authorityInvoker(Authority); + ThrowGraphQLErrorIfNecessary(authorityResponse, true); + } + + /// + async ValueTask IGraphQLAuthorityInvoker.InvokeAllowMissing(Func>> authorityInvoker) + where TApiModel : default + { + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var authorityResponse = await authorityInvoker(Authority); + ThrowGraphQLErrorIfNecessary(authorityResponse, false); + return authorityResponse.Result; + } + + /// + async ValueTask IGraphQLAuthorityInvoker.InvokeTransformableAllowMissing(Func>> authorityInvoker) + where TApiModel : default + { + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var authorityResponse = await authorityInvoker(Authority); + ThrowGraphQLErrorIfNecessary(authorityResponse, false); + var result = authorityResponse.Result; + if (result == null) + return default; + + return result.ToApi(); + } + + /// + ValueTask IGraphQLAuthorityInvoker.Invoke(Func>> authorityInvoker) + => ((IGraphQLAuthorityInvoker)this).InvokeAllowMissing(authorityInvoker)!; + + /// + ValueTask IGraphQLAuthorityInvoker.InvokeTransformable(Func>> authorityInvoker) + => ((IGraphQLAuthorityInvoker)this).InvokeTransformableAllowMissing(authorityInvoker)!; + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs b/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs new file mode 100644 index 0000000000..5aa3c18089 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs @@ -0,0 +1,63 @@ +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Indicates the type of HTTP status code an failing should generate. + /// + public enum HttpFailureResponse + { + /// + /// HTTP 400. + /// + BadRequest, + + /// + /// HTTP 401. + /// + Unauthorized, + + /// + /// HTTP 403. + /// + Forbidden, + + /// + /// HTTP 404. + /// + NotFound, + + /// + /// HTTP 406. + /// + NotAcceptable, + + /// + /// HTTP 409. + /// + Conflict, + + /// + /// HTTP 410. + /// + Gone, + + /// + /// HTTP 422. + /// + UnprocessableEntity, + + /// + /// HTTP 424. + /// + FailedDependency, + + /// + /// HTTP 429. + /// + RateLimited, + + /// + /// HTTP 501. + /// + NotImplemented, + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/HttpSuccessResponse.cs b/src/Tgstation.Server.Host/Authority/Core/HttpSuccessResponse.cs new file mode 100644 index 0000000000..cb7df2a963 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/HttpSuccessResponse.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Indicates the type of HTTP status code a successful should generate. + /// + public enum HttpSuccessResponse + { + /// + /// HTTP 200. + /// + Ok, + + /// + /// HTTP 201. + /// + Created, + + /// + /// HTTP 202. + /// + Accepted, + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/IAuthority.cs b/src/Tgstation.Server.Host/Authority/Core/IAuthority.cs new file mode 100644 index 0000000000..e9b86411f8 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/IAuthority.cs @@ -0,0 +1,11 @@ +#pragma warning disable CA1040 // it's helpful to have a common base class that indicates something is an authority, may remove + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Business logic for interating with the server. + /// + public interface IAuthority + { + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/IAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/IAuthorityInvoker{TAuthority}.cs new file mode 100644 index 0000000000..9b49bd9dda --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/IAuthorityInvoker{TAuthority}.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; + +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Invokes s. + /// + /// The invoked. + public interface IAuthorityInvoker + where TAuthority : IAuthority + { + /// + /// Invoke a method and get the result. + /// + /// The returned . + /// The returning a . + /// A returned. + IQueryable InvokeQueryable(Func> authorityInvoker); + + /// + /// Invoke a method and get the transformed result. + /// + /// The returned by the . + /// The returned . + /// The for converting s to s. + /// The returning a . + /// A returned. + IQueryable InvokeTransformableQueryable(Func> authorityInvoker) + where TResult : IApiTransformable + where TApiModel : notnull + where TTransformer : ITransformer, new(); + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs new file mode 100644 index 0000000000..680dc98a4e --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs @@ -0,0 +1,119 @@ +using System; +using System.Net; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Mvc; + +using Tgstation.Server.Host.Controllers; +using Tgstation.Server.Host.Extensions; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + sealed class RestAuthorityInvoker : AuthorityInvokerBase, IRestAuthorityInvoker + where TAuthority : IAuthority + { + /// + /// Create an for a given successfuly. + /// + /// The to use. + /// A transforming the from the into the . + /// The . + /// An for the . + /// The result returned in the . + /// The REST API result model built from . + static IActionResult CreateSuccessfulActionResult(ApiController controller, Func resultTransformer, AuthorityResponse authorityResponse) + where TApiModel : notnull + { + if (authorityResponse.IsNoContent!.Value) + return controller.NoContent(); + + var successResponse = authorityResponse.SuccessResponse; + var result = resultTransformer(authorityResponse.Result!); + return successResponse switch + { + HttpSuccessResponse.Ok => controller.Json(result), + HttpSuccessResponse.Created => controller.Created(result), + HttpSuccessResponse.Accepted => controller.Accepted(result), + _ => throw new InvalidOperationException($"Invalid {nameof(HttpSuccessResponse)}: {successResponse}"), + }; + } + + /// + /// Create an for a given if it is erroring. + /// + /// The to use. + /// The . + /// An if the is not successful, otherwise. + static IActionResult? CreateErroredActionResult(ApiController controller, AuthorityResponse authorityResponse) + { + if (authorityResponse.Success) + return null; + + var errorMessage = authorityResponse.ErrorMessage; + var failureResponse = authorityResponse.FailureResponse; + return failureResponse switch + { + HttpFailureResponse.BadRequest => controller.BadRequest(errorMessage), + HttpFailureResponse.Unauthorized => controller.Unauthorized(errorMessage), + HttpFailureResponse.Forbidden => controller.Forbid(), + HttpFailureResponse.NotFound => controller.NotFound(errorMessage), + HttpFailureResponse.NotAcceptable => controller.StatusCode(HttpStatusCode.NotAcceptable, errorMessage), + HttpFailureResponse.Conflict => controller.Conflict(errorMessage), + HttpFailureResponse.Gone => controller.StatusCode(HttpStatusCode.Gone, errorMessage), + HttpFailureResponse.UnprocessableEntity => controller.UnprocessableEntity(errorMessage), + HttpFailureResponse.FailedDependency => controller.StatusCode(HttpStatusCode.FailedDependency, errorMessage), + HttpFailureResponse.RateLimited => controller.StatusCode(HttpStatusCode.TooManyRequests, errorMessage), + HttpFailureResponse.NotImplemented => controller.StatusCode(HttpStatusCode.NotImplemented, errorMessage), + _ => throw new InvalidOperationException($"Invalid {nameof(HttpFailureResponse)}: {failureResponse}"), + }; + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + public RestAuthorityInvoker(TAuthority authority) + : base(authority) + { + } + + /// + async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func> authorityInvoker) + { + ArgumentNullException.ThrowIfNull(controller); + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var authorityResponse = await authorityInvoker(Authority); + return CreateErroredActionResult(controller, authorityResponse) ?? controller.NoContent(); + } + + /// + async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func>> authorityInvoker) + { + ArgumentNullException.ThrowIfNull(controller); + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var authorityResponse = await authorityInvoker(Authority); + var erroredResult = CreateErroredActionResult(controller, authorityResponse); + if (erroredResult != null) + return erroredResult; + + return CreateSuccessfulActionResult(controller, result => result, authorityResponse); + } + + /// + async ValueTask IRestAuthorityInvoker.InvokeTransformable(ApiController controller, Func>> authorityInvoker) + { + ArgumentNullException.ThrowIfNull(controller); + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var authorityResponse = await authorityInvoker(Authority); + var erroredResult = CreateErroredActionResult(controller, authorityResponse); + if (erroredResult != null) + return erroredResult; + + return CreateSuccessfulActionResult(controller, result => result.ToApi(), authorityResponse); + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs new file mode 100644 index 0000000000..edface219a --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs @@ -0,0 +1,71 @@ +using System; +using System.Threading.Tasks; + +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Authority +{ + /// + /// Invokes s from GraphQL endpoints. + /// + /// The invoked. + public interface IGraphQLAuthorityInvoker : IAuthorityInvoker + where TAuthority : IAuthority + { + /// + /// Invoke a method with no success result. + /// + /// The returning a resulting in the . + /// A representing the running operation. + ValueTask Invoke(Func> authorityInvoker); + + /// + /// Invoke a method and get the result. + /// + /// The . + /// The resulting of the return value. + /// The returning a resulting in the . + /// A resulting in the generated for the resulting . + ValueTask InvokeAllowMissing(Func>> authorityInvoker) + where TResult : TApiModel + where TApiModel : notnull; + + /// + /// Invoke a method and get the result. + /// + /// The . + /// The resulting of the return value. + /// The for converting s to s. + /// The returning a resulting in the . + /// A resulting in the generated for the resulting . + ValueTask InvokeTransformableAllowMissing(Func>> authorityInvoker) + where TResult : notnull, IApiTransformable + where TApiModel : notnull + where TTransformer : ITransformer, new(); + + /// + /// Invoke a method and get the non-nullable result. + /// + /// The . + /// The resulting of the return value. + /// The returning a resulting in the . + /// A resulting in the generated for the resulting . + ValueTask Invoke(Func>> authorityInvoker) + where TResult : TApiModel + where TApiModel : notnull; + + /// + /// Invoke a method and get the non-nullable result. + /// + /// The . + /// The resulting of the return value. + /// The for converting s to s. + /// The returning a resulting in the . + /// A resulting in the generated for the resulting . + ValueTask InvokeTransformable(Func>> authorityInvoker) + where TResult : notnull, IApiTransformable + where TApiModel : notnull + where TTransformer : ITransformer, new(); + } +} diff --git a/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs new file mode 100644 index 0000000000..b55e94be64 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; + +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.GraphQL.Mutations.Payloads; + +namespace Tgstation.Server.Host.Authority +{ + /// + /// for authenticating with the server. + /// + public interface ILoginAuthority : IAuthority + { + /// + /// Attempt to login to the server with the current crentials. + /// + /// The for the operation. + /// A resulting in a and . + ValueTask> AttemptLogin(CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Authority/IPermissionSetAuthority.cs b/src/Tgstation.Server.Host/Authority/IPermissionSetAuthority.cs new file mode 100644 index 0000000000..bb10fed383 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/IPermissionSetAuthority.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; + +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Authority +{ + /// + /// for managing s. + /// + public interface IPermissionSetAuthority : IAuthority + { + /// + /// Gets the with a given . + /// + /// The to lookup. + /// The of . + /// The for the operation. + /// A resulting in a . + [TgsAuthorize(AdministrationRights.ReadUsers)] + ValueTask> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Authority/IRestAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/IRestAuthorityInvoker{TAuthority}.cs new file mode 100644 index 0000000000..2cb05a2b9a --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/IRestAuthorityInvoker{TAuthority}.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Mvc; + +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Controllers; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Authority +{ + /// + /// Invokes methods and generates responses. + /// + /// The type of . + public interface IRestAuthorityInvoker : IAuthorityInvoker + where TAuthority : IAuthority + { + /// + /// Invoke a method with no success result. + /// + /// The invoking the . + /// The returning a resulting in the . + /// A resulting in the generated for the resulting . + ValueTask Invoke(ApiController controller, Func> authorityInvoker); + + /// + /// Invoke a method and get the result. + /// + /// The . + /// The resulting of the . + /// The invoking the . + /// The returning a resulting in the . + /// A resulting in the generated for the resulting . + ValueTask Invoke(ApiController controller, Func>> authorityInvoker) + where TResult : TApiModel + where TApiModel : notnull; + + /// + /// Invoke a method and get the result. + /// + /// The . + /// The returned REST . + /// The invoking the . + /// The returning a resulting in the . + /// A resulting in the generated for the resulting . + ValueTask InvokeTransformable(ApiController controller, Func>> authorityInvoker) + where TResult : notnull, ILegacyApiTransformable + where TApiModel : notnull; + } +} diff --git a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs new file mode 100644 index 0000000000..9e2a37da59 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs @@ -0,0 +1,76 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Request; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Authority +{ + /// + /// for managing s. + /// + public interface IUserAuthority : IAuthority + { + /// + /// Gets the currently authenticated user. + /// + /// The for the operation. + /// A resulting in a . + [TgsAuthorize] + ValueTask> Read(CancellationToken cancellationToken); + + /// + /// Gets the with a given . + /// + /// The of the . + /// If related entities should be loaded. + /// If the may be returned. + /// The for the operation. + /// A resulting in a . + [TgsAuthorize(AdministrationRights.ReadUsers)] + ValueTask> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken); + + /// + /// Gets the s for the with a given . + /// + /// The of the . + /// The for the operation. + /// A resulting in an of . + ValueTask> OAuthConnections(long userId, CancellationToken cancellationToken); + + /// + /// Gets all registered s. + /// + /// If related entities should be loaded. + /// A of s. + [TgsAuthorize(AdministrationRights.ReadUsers)] + IQueryable Queryable(bool includeJoins); + + /// + /// Creates a . + /// + /// The . + /// If a zero-length indicates and OAuth only user. + /// The for the operation. + /// A resulting in am for the created . + [TgsAuthorize(AdministrationRights.WriteUsers)] + ValueTask> Create( + UserCreateRequest createRequest, + bool? needZeroLengthPasswordWithOAuthConnections, + CancellationToken cancellationToken); + + /// + /// Updates a . + /// + /// The . + /// The for the operation. + /// A resulting in am for the created . + [TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnOAuthConnections)] + ValueTask> Update(UserUpdateRequest updateRequest, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs new file mode 100644 index 0000000000..28113b95f5 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs @@ -0,0 +1,71 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Authority +{ + /// + /// for managing s. + /// + public interface IUserGroupAuthority : IAuthority + { + /// + /// Gets the current . + /// + /// A resulting in a . + ValueTask> Read(); + + /// + /// Gets the with a given . + /// + /// The of the . + /// If related entities should be loaded. + /// The for the operation. + /// A resulting in a . + [TgsAuthorize(AdministrationRights.ReadUsers)] + ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken); + + /// + /// Gets all registered s. + /// + /// If related entities should be loaded. + /// A of s. + [TgsAuthorize(AdministrationRights.ReadUsers)] + IQueryable Queryable(bool includeJoins); + + /// + /// Create a . + /// + /// The created 's . + /// The created 's . + /// The for the operation. + /// A resulting in a . + [TgsAuthorize(AdministrationRights.WriteUsers)] + ValueTask> Create(string name, PermissionSet? permissionSet, CancellationToken cancellationToken); + + /// + /// Updates a . + /// + /// The of the to update. + /// The optional new for the . + /// The optional new for the . + /// The for the operation. + /// A resulting in a . + [TgsAuthorize(AdministrationRights.WriteUsers)] + ValueTask> Update(long id, string? newName, PermissionSet? newPermissionSet, CancellationToken cancellationToken); + + /// + /// Deletes an empty . + /// + /// The of the to delete. + /// The for the operation. + /// A representing the running operation. + [TgsAuthorize(AdministrationRights.WriteUsers)] + ValueTask DeleteEmpty(long id, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs new file mode 100644 index 0000000000..9e0602d07e --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs @@ -0,0 +1,300 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.GraphQL.Mutations.Payloads; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Models.Transformers; +using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Security.OAuth; +using Tgstation.Server.Host.Utils; + +namespace Tgstation.Server.Host.Authority +{ + /// + sealed class LoginAuthority : AuthorityBase, ILoginAuthority + { + /// + /// The for the . + /// + readonly IApiHeadersProvider apiHeadersProvider; + + /// + /// The for the . + /// + readonly ISystemIdentityFactory systemIdentityFactory; + + /// + /// The for the . + /// + readonly IOAuthProviders oAuthProviders; + + /// + /// The for the . + /// + readonly ITokenFactory tokenFactory; + + /// + /// The for the . + /// + readonly ICryptographySuite cryptographySuite; + + /// + /// The for the . + /// + readonly IIdentityCache identityCache; + + /// + /// The for the . + /// + readonly ISessionInvalidationTracker sessionInvalidationTracker; + + /// + /// Generate an for a given . + /// + /// The to generate a response for. + /// A new, errored . + static AuthorityResponse GenerateHeadersExceptionResponse(HeadersException headersException) + => new( + new ErrorMessageResponse(ErrorCode.BadHeaders) + { + AdditionalData = headersException.Message, + }, + headersException.ParseErrors.HasFlag(HeaderErrorTypes.Accept) + ? HttpFailureResponse.NotAcceptable + : HttpFailureResponse.BadRequest); + + /// + /// Select the details needed to generate a from a given . + /// + /// The of s. + /// The for the operation. + /// A resulting in the returned after selecting, if any. + static async ValueTask SelectUserInfoFromQuery(IQueryable query, CancellationToken cancellationToken) + { + var users = await query + .ToListAsync(cancellationToken); + + // Pick the DB user first + var user = users + .OrderByDescending(dbUser => dbUser.SystemIdentifier == null) + .FirstOrDefault(); + + return user; + } + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The to use. + /// The to use. + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The value of . + public LoginAuthority( + IAuthenticationContext authenticationContext, + IDatabaseContext databaseContext, + ILogger logger, + IApiHeadersProvider apiHeadersProvider, + ISystemIdentityFactory systemIdentityFactory, + IOAuthProviders oAuthProviders, + ITokenFactory tokenFactory, + ICryptographySuite cryptographySuite, + IIdentityCache identityCache, + ISessionInvalidationTracker sessionInvalidationTracker) + : base( + authenticationContext, + databaseContext, + logger) + { + this.apiHeadersProvider = apiHeadersProvider ?? throw new ArgumentNullException(nameof(apiHeadersProvider)); + this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); + this.oAuthProviders = oAuthProviders ?? throw new ArgumentNullException(nameof(oAuthProviders)); + this.tokenFactory = tokenFactory ?? throw new ArgumentNullException(nameof(tokenFactory)); + this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); + this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache)); + this.sessionInvalidationTracker = sessionInvalidationTracker ?? throw new ArgumentNullException(nameof(sessionInvalidationTracker)); + } + + /// + public async ValueTask> AttemptLogin(CancellationToken cancellationToken) + { + var headers = apiHeadersProvider.ApiHeaders; + if (headers == null) + return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!); + + if (headers.IsTokenAuthentication) + return BadRequest(ErrorCode.TokenWithToken); + + var oAuthLogin = headers.OAuthProvider.HasValue; + + ISystemIdentity? systemIdentity = null; + if (!oAuthLogin) + try + { + // trust the system over the database because a user's name can change while still having the same SID + systemIdentity = await systemIdentityFactory.CreateSystemIdentity(headers.Username!, headers.Password!, cancellationToken); + } + catch (NotImplementedException) + { + // Intentionally suppressed + } + + using (systemIdentity) + { + // Get the user from the database + IQueryable query = DatabaseContext.Users.AsQueryable(); + if (oAuthLogin) + { + var oAuthProvider = headers.OAuthProvider!.Value; + string? externalUserId; + try + { + var validator = oAuthProviders + .GetValidator(oAuthProvider); + + if (validator == null) + return BadRequest(ErrorCode.OAuthProviderDisabled); + + externalUserId = await validator + .ValidateResponseCode(headers.OAuthCode!, cancellationToken); + + Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, externalUserId); + } + catch (Octokit.RateLimitExceededException ex) + { + return RateLimit(ex); + } + + if (externalUserId == null) + return Unauthorized(); + + query = query.Where( + x => x.OAuthConnections!.Any( + y => y.Provider == oAuthProvider + && y.ExternalUserId == externalUserId)); + } + else + { + var canonicalUserName = User.CanonicalizeName(headers.Username!); + if (canonicalUserName == User.CanonicalizeName(User.TgsSystemUserName)) + return Unauthorized(); + + if (systemIdentity == null) + query = query.Where(x => x.CanonicalName == canonicalUserName); + else + query = query.Where(x => x.CanonicalName == canonicalUserName || x.SystemIdentifier == systemIdentity.Uid); + } + + var user = await SelectUserInfoFromQuery(query, cancellationToken); + + // No user? You're not allowed + if (user == null) + return Unauthorized(); + + // A system user may have had their name AND password changed to one in our DB... + // Or a DB user was created that had the same user/pass as a system user + // Dumb admins... + // FALLBACK TO THE DB USER HERE, DO NOT REVEAL A SYSTEM LOGIN!!! + // This of course, allows system users to discover TGS users in this (HIGHLY IMPROBABLE) case but that is not our fault + var originalHash = user.PasswordHash; + var isLikelyDbUser = originalHash != null; + var usingSystemIdentity = systemIdentity != null && !isLikelyDbUser; + if (!oAuthLogin) + if (!usingSystemIdentity) + { + // DB User password check and update + if (!isLikelyDbUser || !cryptographySuite.CheckUserPassword(user, headers.Password!)) + return Unauthorized(); + if (user.PasswordHash != originalHash) + { + Logger.LogDebug("User ID {userId}'s password hash needs a refresh, updating database.", user.Id); + var updatedUser = new User + { + Id = user.Id, + }; + DatabaseContext.Users.Attach(updatedUser); + updatedUser.PasswordHash = user.PasswordHash; + await DatabaseContext.Save(cancellationToken); + } + } + else + { + var usernameMismatch = systemIdentity!.Username != user.Name; + if (isLikelyDbUser || usernameMismatch) + { + DatabaseContext.Users.Attach(user); + if (usernameMismatch) + { + // System identity username change update + Logger.LogDebug("User ID {userId}'s system identity needs a refresh, updating database.", user.Id); + user.Name = systemIdentity.Username; + user.CanonicalName = User.CanonicalizeName(user.Name); + } + + if (isLikelyDbUser) + { + // cleanup from https://github.com/tgstation/tgstation-server/issues/1528 + Logger.LogDebug("System user ID {userId}'s PasswordHash is polluted, updating database.", user.Id); + user.PasswordHash = null; + sessionInvalidationTracker.UserModifiedInvalidateSessions(user); + } + + await DatabaseContext.Save(cancellationToken); + } + } + + // Now that the bookeeping is done, tell them to fuck off if necessary + if (!user.Enabled!.Value) + { + Logger.LogTrace("Not logging in disabled user {userId}.", user.Id); + return Forbid(); + } + + var token = tokenFactory.CreateToken(user, oAuthLogin); + var payload = new LoginResult + { + Bearer = token, + User = ((IApiTransformable)user).ToApi(), + }; + + if (usingSystemIdentity) + await CacheSystemIdentity(systemIdentity!, user, payload); + + Logger.LogDebug("Successfully logged in user {userId}!", user.Id); + + return new AuthorityResponse(payload); + } + } + + /// + /// Add a given to the . + /// + /// The to cache. + /// The the was generated for. + /// The for the successful login. + /// A representing the running operation. + private async ValueTask CacheSystemIdentity(ISystemIdentity systemIdentity, User user, LoginResult loginPayload) + { + // expire the identity slightly after the auth token in case of lag + var identExpiry = loginPayload.ToApi().ParseJwt().ValidTo; + identExpiry += tokenFactory.ValidationParameters.ClockSkew; + identExpiry += TimeSpan.FromSeconds(15); + await identityCache.CacheSystemIdentity(user, systemIdentity!, identExpiry); + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/PermissionSetAuthority.cs b/src/Tgstation.Server.Host/Authority/PermissionSetAuthority.cs new file mode 100644 index 0000000000..12e63a9ce6 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/PermissionSetAuthority.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using GreenDonut; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Authority +{ + /// + sealed class PermissionSetAuthority : AuthorityBase, IPermissionSetAuthority + { + /// + /// The for the . + /// + readonly IPermissionSetsDataLoader permissionSetsDataLoader; + + /// + /// Implements . + /// + /// The of IDs and their s to load. + /// The to load from. + /// The for the operation. + /// A resulting in a of the requested s. + [DataLoader] + public static async ValueTask> GetPermissionSets( + IReadOnlyList<(long Id, PermissionSetLookupType LookupType)> ids, + IDatabaseContext databaseContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(ids); + ArgumentNullException.ThrowIfNull(databaseContext); + + var idLookups = new List(ids.Count); + var userIdLookups = new List(ids.Count); + var groupIdLookups = new List(ids.Count); + + foreach (var (id, lookupType) in ids) + switch (lookupType) + { + case PermissionSetLookupType.Id: + idLookups.Add(id); + break; + case PermissionSetLookupType.UserId: + userIdLookups.Add(id); + break; + case PermissionSetLookupType.GroupId: + groupIdLookups.Add(id); + break; + default: + throw new InvalidOperationException($"Invalid {nameof(PermissionSetLookupType)}: {lookupType}"); + } + + var selectedPermissionSets = await databaseContext + .PermissionSets + .Where(dbModel => idLookups.Contains(dbModel.Id!.Value) + || (dbModel.UserId.HasValue && userIdLookups.Contains(dbModel.UserId.Value)) + || (dbModel.GroupId.HasValue && groupIdLookups.Contains(dbModel.GroupId.Value))) + .ToListAsync(cancellationToken); + + var results = new Dictionary<(long Id, PermissionSetLookupType LookupType), PermissionSet>(selectedPermissionSets.Count * 2); + foreach (var permissionSet in selectedPermissionSets) + { + results.Add((permissionSet.Id!.Value, PermissionSetLookupType.Id), permissionSet); + if (permissionSet.GroupId.HasValue) + results.Add((permissionSet.GroupId.Value, PermissionSetLookupType.GroupId), permissionSet); + if (permissionSet.UserId.HasValue) + results.Add((permissionSet.UserId.Value, PermissionSetLookupType.UserId), permissionSet); + } + + return results; + } + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The to use. + /// The to use. + /// The value of . + public PermissionSetAuthority( + IAuthenticationContext authenticationContext, + IDatabaseContext databaseContext, + ILogger logger, + IPermissionSetsDataLoader permissionSetsDataLoader) + : base( + authenticationContext, + databaseContext, + logger) + { + this.permissionSetsDataLoader = permissionSetsDataLoader ?? throw new ArgumentNullException(nameof(permissionSetsDataLoader)); + } + + /// + public async ValueTask> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken) + { + if (id != AuthenticationContext.PermissionSet.Id && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers)) + return Forbid(); + + var permissionSet = await permissionSetsDataLoader.LoadAsync((Id: id, LookupType: lookupType), cancellationToken); + if (permissionSet == null) + return NotFound(); + + return new AuthorityResponse(permissionSet); + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/PermissionSetLookupType.cs b/src/Tgstation.Server.Host/Authority/PermissionSetLookupType.cs new file mode 100644 index 0000000000..12925dbf8f --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/PermissionSetLookupType.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Host.Authority +{ + /// + /// Indicates the type of to lookup on a . + /// + public enum PermissionSetLookupType + { + /// + /// Lookup the of the . + /// + Id, + + /// + /// Lookup the of the . + /// + UserId, + + /// + /// Lookup the of the . + /// + GroupId, + } +} diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs new file mode 100644 index 0000000000..686eb10f47 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -0,0 +1,630 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using GreenDonut; + +using HotChocolate.Subscriptions; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Request; +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Common.Extensions; +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Models.Transformers; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Authority +{ + /// + sealed class UserAuthority : AuthorityBase, IUserAuthority + { + /// + /// The for the . + /// + readonly IUsersDataLoader usersDataLoader; + + /// + /// The for the . + /// + readonly IOAuthConnectionsDataLoader oAuthConnectionsDataLoader; + + /// + /// The for the . + /// + readonly ISystemIdentityFactory systemIdentityFactory; + + /// + /// The for the . + /// + readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee; + + /// + /// The for the . + /// + readonly ICryptographySuite cryptographySuite; + + /// + /// The for the . + /// + readonly ISessionInvalidationTracker sessionInvalidationTracker; + + /// + /// The for the . + /// + readonly ITopicEventSender topicEventSender; + + /// + /// The of for the . + /// + readonly IOptionsSnapshot generalConfigurationOptions; + + /// + /// Implements the . + /// + /// The of s to load. + /// The to load from. + /// The for the operation. + /// A resulting in a of the requested s. + [DataLoader] + public static Task> GetUsers( + IReadOnlyList ids, + IDatabaseContext databaseContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(ids); + ArgumentNullException.ThrowIfNull(databaseContext); + + return databaseContext + .Users + .AsQueryable() + .Where(x => ids.Contains(x.Id!.Value)) + .ToDictionaryAsync(user => user.Id!.Value, cancellationToken); + } + + /// + /// Implements the . + /// + /// The of s to load the OAuthConnections for. + /// The to load from. + /// The for the operation. + /// A resulting in a of the requested s. + [DataLoader] + public static async ValueTask> GetOAuthConnections( + IReadOnlyList userIds, + IDatabaseContext databaseContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userIds); + ArgumentNullException.ThrowIfNull(databaseContext); + + var list = await databaseContext + .OAuthConnections + .AsQueryable() + .Where(x => userIds.Contains(x.User!.Id!.Value)) + .ToListAsync(cancellationToken); + + return list.ToLookup( + oauthConnection => oauthConnection.UserId, + x => new GraphQL.Types.OAuth.OAuthConnection(x.ExternalUserId!, x.Provider)); + } + + /// + /// Check if a given has a valid specified. + /// + /// The to check. + /// If this is a new . + /// if is valid, an errored otherwise. + static AuthorityResponse? CheckValidName(UserUpdateRequest model, bool newUser) + { + var userInvalidWithNullName = newUser && model.Name == null && model.SystemIdentifier == null; + if (userInvalidWithNullName || (model.Name != null && String.IsNullOrWhiteSpace(model.Name))) + return BadRequest(ErrorCode.UserMissingName); + + model.Name = model.Name?.Trim(); + if (model.Name != null && model.Name.Contains(':', StringComparison.InvariantCulture)) + return BadRequest(ErrorCode.UserColonInName); + return null; + } + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The to use. + /// The to use. + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The value of . + public UserAuthority( + IAuthenticationContext authenticationContext, + IDatabaseContext databaseContext, + ILogger logger, + IUsersDataLoader usersDataLoader, + IOAuthConnectionsDataLoader oAuthConnectionsDataLoader, + ISystemIdentityFactory systemIdentityFactory, + IPermissionsUpdateNotifyee permissionsUpdateNotifyee, + ICryptographySuite cryptographySuite, + ISessionInvalidationTracker sessionInvalidationTracker, + ITopicEventSender topicEventSender, + IOptionsSnapshot generalConfigurationOptions) + : base( + authenticationContext, + databaseContext, + logger) + { + this.usersDataLoader = usersDataLoader ?? throw new ArgumentNullException(nameof(usersDataLoader)); + this.oAuthConnectionsDataLoader = oAuthConnectionsDataLoader ?? throw new ArgumentNullException(nameof(oAuthConnectionsDataLoader)); + this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); + this.permissionsUpdateNotifyee = permissionsUpdateNotifyee ?? throw new ArgumentNullException(nameof(permissionsUpdateNotifyee)); + this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); + this.sessionInvalidationTracker = sessionInvalidationTracker ?? throw new ArgumentNullException(nameof(sessionInvalidationTracker)); + this.topicEventSender = topicEventSender ?? throw new ArgumentNullException(nameof(topicEventSender)); + this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + } + + /// + /// Checks if a should return a bad request . + /// + /// The to check. + /// If a zero-length indicates and OAuth only user. + /// The output failing , if any. + /// if checks failed and was populated, otherwise. + static bool BadCreateRequestChecks( + UserCreateRequest createRequest, + bool? needZeroLengthPasswordWithOAuthConnections, + [NotNullWhen(true)] out AuthorityResponse? failResponse) + { + if (createRequest.OAuthConnections?.Any(x => x == null) == true) + { + failResponse = BadRequest(ErrorCode.ModelValidationFailure); + return true; + } + + var hasNonNullPassword = createRequest.Password != null; + var hasNonNullSystemIdentifier = createRequest.SystemIdentifier != null; + var hasOAuthConnections = (createRequest.OAuthConnections?.Count > 0) == true; + if ((hasNonNullPassword && hasNonNullSystemIdentifier) + || (!hasNonNullPassword && !hasNonNullSystemIdentifier && !hasOAuthConnections)) + { + failResponse = BadRequest(ErrorCode.UserMismatchPasswordSid); + return true; + } + + var hasZeroLengthPassword = createRequest.Password?.Length == 0; + if (needZeroLengthPasswordWithOAuthConnections.HasValue) + { + if (needZeroLengthPasswordWithOAuthConnections.Value) + { + if (createRequest.OAuthConnections == null) + throw new InvalidOperationException($"Expected {nameof(UserCreateRequest.OAuthConnections)} to be set here!"); + + if (createRequest.OAuthConnections.Count == 0) + { + failResponse = BadRequest(ErrorCode.ModelValidationFailure); + return true; + } + } + else if (hasZeroLengthPassword) + { + failResponse = BadRequest(ErrorCode.ModelValidationFailure); + return true; + } + } + + if (createRequest.Group != null && createRequest.PermissionSet != null) + { + failResponse = BadRequest(ErrorCode.UserGroupAndPermissionSet); + return true; + } + + createRequest.Name = createRequest.Name?.Trim(); + if (createRequest.Name?.Length == 0) + createRequest.Name = null; + + if (!(createRequest.Name == null ^ createRequest.SystemIdentifier == null)) + { + failResponse = BadRequest(ErrorCode.UserMismatchNameSid); + return true; + } + + failResponse = CheckValidName(createRequest, true); + return failResponse != null; + } + + /// + public ValueTask> Read(CancellationToken cancellationToken) + => ValueTask.FromResult(new AuthorityResponse(AuthenticationContext.User)); + + /// + public async ValueTask> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken) + { + if (id != AuthenticationContext.User.Id && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers)) + return Forbid(); + + User? user; + if (includeJoins) + { + var queryable = Queryable(true, true); + + user = await queryable.FirstOrDefaultAsync( + dbModel => dbModel.Id == id, + cancellationToken); + } + else + user = await usersDataLoader.LoadAsync(id, cancellationToken); + + if (user == default) + return NotFound(); + + if (!allowSystemUser && user.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName)) + return Forbid(); + + return new AuthorityResponse(user); + } + + /// + public IQueryable Queryable(bool includeJoins) + => Queryable(includeJoins, false); + + /// + public async ValueTask> OAuthConnections(long userId, CancellationToken cancellationToken) + => new AuthorityResponse( + await oAuthConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken)); + + /// + public async ValueTask> Create( + UserCreateRequest createRequest, + bool? needZeroLengthPasswordWithOAuthConnections, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(createRequest); + + if (BadCreateRequestChecks(createRequest, needZeroLengthPasswordWithOAuthConnections, out var failResponse)) + return failResponse; + + var totalUsers = await DatabaseContext + .Users + .AsQueryable() + .CountAsync(cancellationToken); + if (totalUsers >= generalConfigurationOptions.Value.UserLimit) + return Conflict(ErrorCode.UserLimitReached); + + var dbUser = await CreateNewUserFromModel(createRequest, cancellationToken); + if (dbUser == null) + return Gone(); + + if (createRequest.SystemIdentifier != null) + try + { + using var sysIdentity = await systemIdentityFactory.CreateSystemIdentity(dbUser, cancellationToken); + if (sysIdentity == null) + return Gone(); + dbUser.Name = sysIdentity.Username; + dbUser.SystemIdentifier = sysIdentity.Uid; + } + catch (NotImplementedException ex) + { + Logger.LogTrace(ex, "System identities not implemented!"); + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.RequiresPosixSystemIdentity), + HttpFailureResponse.NotImplemented); + } + else + { + var hasZeroLengthPassword = createRequest.Password?.Length == 0; + var hasOAuthConnections = (createRequest.OAuthConnections?.Count > 0) == true; + + // special case allow PasswordHash to be null by setting Password to "" if OAuthConnections are set + if (!(needZeroLengthPasswordWithOAuthConnections != false && hasZeroLengthPassword && hasOAuthConnections)) + { + var result = TrySetPassword(dbUser, createRequest.Password!, true); + if (result != null) + return result; + } + } + + dbUser.CanonicalName = User.CanonicalizeName(dbUser.Name!); + + DatabaseContext.Users.Add(dbUser); + + await DatabaseContext.Save(cancellationToken); + + Logger.LogInformation("Created new user {name} ({id})", dbUser.Name, dbUser.Id); + + await SendUserUpdatedTopics(dbUser); + + return new AuthorityResponse(dbUser, HttpSuccessResponse.Created); + } + + /// +#pragma warning disable CA1502 +#pragma warning disable CA1506 // TODO: Decomplexify + public async ValueTask> Update(UserUpdateRequest model, CancellationToken cancellationToken) +#pragma warning restore CA1502 +#pragma warning restore CA1506 + { + ArgumentNullException.ThrowIfNull(model); + + if (!model.Id.HasValue || model.OAuthConnections?.Any(x => x == null) == true) + return BadRequest(ErrorCode.ModelValidationFailure); + + if (model.Group != null && model.PermissionSet != null) + return BadRequest(ErrorCode.UserGroupAndPermissionSet); + + var callerAdministrationRights = (AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration); + var canEditAllUsers = callerAdministrationRights.HasFlag(AdministrationRights.WriteUsers); + var passwordEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnPassword); + var oAuthEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnOAuthConnections); + + var originalUser = !canEditAllUsers + ? AuthenticationContext.User + : await DatabaseContext + .Users + .AsQueryable() + .Where(x => x.Id == model.Id) + .Include(x => x.CreatedBy) + .Include(x => x.OAuthConnections) + .Include(x => x.Group!) + .ThenInclude(x => x.PermissionSet) + .Include(x => x.PermissionSet) + .FirstOrDefaultAsync(cancellationToken); + + if (originalUser == default) + return NotFound(); + + if (originalUser.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName)) + return Forbid(); + + // Ensure they are only trying to edit things they have perms for (system identity change will trigger a bad request) + if ((!canEditAllUsers + && (model.Id != originalUser.Id + || model.Enabled.HasValue + || model.Group != null + || model.PermissionSet != null + || model.Name != null)) + || (!passwordEdit && model.Password != null) + || (!oAuthEdit && model.OAuthConnections != null)) + return Forbid(); + + var originalUserHasSid = originalUser.SystemIdentifier != null; + var invalidateSessions = false; + if (originalUserHasSid && originalUser.PasswordHash != null) + { + // cleanup from https://github.com/tgstation/tgstation-server/issues/1528 + Logger.LogDebug("System user ID {userId}'s PasswordHash is polluted, updating database.", originalUser.Id); + originalUser.PasswordHash = null; + + invalidateSessions = true; + } + + if (model.SystemIdentifier != null && model.SystemIdentifier != originalUser.SystemIdentifier) + return BadRequest(ErrorCode.UserSidChange); + + if (model.Password != null) + { + if (originalUserHasSid) + return BadRequest(ErrorCode.UserMismatchPasswordSid); + + var result = TrySetPassword(originalUser, model.Password, false); + if (result != null) + return result; + + invalidateSessions = true; + } + + if (model.Name != null && User.CanonicalizeName(model.Name) != originalUser.CanonicalName) + return BadRequest(ErrorCode.UserNameChange); + + if (model.OAuthConnections != null + && (model.OAuthConnections.Count != originalUser.OAuthConnections!.Count + || !model.OAuthConnections.All(x => originalUser.OAuthConnections.Any(y => y.Provider == x.Provider && y.ExternalUserId == x.ExternalUserId)))) + { + if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName)) + return BadRequest(ErrorCode.AdminUserCannotOAuth); + + if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null) + return BadRequest(ErrorCode.CannotRemoveLastAuthenticationOption); + + originalUser.OAuthConnections.Clear(); + foreach (var updatedConnection in model.OAuthConnections) + originalUser.OAuthConnections.Add(new Models.OAuthConnection + { + Provider = updatedConnection.Provider, + ExternalUserId = updatedConnection.ExternalUserId, + }); + } + + if (model.Group != null) + { + originalUser.Group = await DatabaseContext + .Groups + .AsQueryable() + .Where(x => x.Id == model.Group.Id) + .Include(x => x.PermissionSet) + .FirstOrDefaultAsync(cancellationToken); + + if (originalUser.Group == default) + return Gone(); + + DatabaseContext.Groups.Attach(originalUser.Group); + if (originalUser.PermissionSet != null) + { + Logger.LogInformation("Deleting permission set {permissionSetId}...", originalUser.PermissionSet.Id); + DatabaseContext.PermissionSets.Remove(originalUser.PermissionSet); + originalUser.PermissionSet = null; + } + } + else if (model.PermissionSet != null) + { + if (originalUser.PermissionSet == null) + { + Logger.LogTrace("Creating new permission set..."); + originalUser.PermissionSet = new Models.PermissionSet(); + } + + originalUser.PermissionSet.AdministrationRights = model.PermissionSet.AdministrationRights ?? AdministrationRights.None; + originalUser.PermissionSet.InstanceManagerRights = model.PermissionSet.InstanceManagerRights ?? InstanceManagerRights.None; + + originalUser.Group = null; + originalUser.GroupId = null; + } + + var fail = CheckValidName(model, false); + if (fail != null) + return fail; + + originalUser.Name = model.Name ?? originalUser.Name; + + if (model.Enabled.HasValue) + { + invalidateSessions = originalUser.Require(x => x.Enabled) && !model.Enabled.Value; + originalUser.Enabled = model.Enabled.Value; + } + + if (invalidateSessions) + sessionInvalidationTracker.UserModifiedInvalidateSessions(originalUser); + + await DatabaseContext.Save(cancellationToken); + + Logger.LogInformation("Updated user {userName} ({userId})", originalUser.Name, originalUser.Id); + + if (invalidateSessions) + await permissionsUpdateNotifyee.UserDisabled(originalUser, cancellationToken); + + await SendUserUpdatedTopics(originalUser); + + // return id only if not a self update and cannot read users + var canReadBack = AuthenticationContext.User.Id == originalUser.Id + || callerAdministrationRights.HasFlag(AdministrationRights.ReadUsers); + return canReadBack + ? new AuthorityResponse(originalUser) + : new AuthorityResponse(); + } + + /// + /// Send topics through the indicating a given was created or updated. + /// + /// The that was created or updated. + /// A representing the running operation. + ValueTask SendUserUpdatedTopics(User user) + => ValueTaskExtensions.WhenAll( + GraphQL.Subscriptions.UserSubscriptions.UserUpdatedTopics( + user.Require(x => x.Id)) + .Select(topic => topicEventSender.SendAsync( + topic, + ((IApiTransformable)user).ToApi(), + CancellationToken.None))); // DCT: Operation should always run + + /// + /// Gets all registered s. + /// + /// If related entities should be loaded. + /// If the with the should be included in results. + /// A of s. + IQueryable Queryable(bool includeJoins, bool allowSystemUser) + { + var tgsUserCanonicalName = User.CanonicalizeName(User.TgsSystemUserName); + var queryable = DatabaseContext + .Users + .AsQueryable(); + + if (!allowSystemUser) + queryable = queryable + .Where(user => user.CanonicalName != tgsUserCanonicalName); + + if (includeJoins) + queryable = queryable + .Include(x => x.CreatedBy) + .Include(x => x.OAuthConnections) + .Include(x => x.Group!) + .ThenInclude(x => x.PermissionSet) + .Include(x => x.PermissionSet); + + return queryable; + } + + /// + /// Creates a new from a given . + /// + /// The to use as a template. + /// The for the operation. + /// A resulting in a new on success, if the requested did not exist. + async ValueTask CreateNewUserFromModel(Api.Models.Internal.UserApiBase model, CancellationToken cancellationToken) + { + Models.PermissionSet? permissionSet = null; + UserGroup? group = null; + if (model.Group != null) + group = await DatabaseContext + .Groups + .AsQueryable() + .Where(x => x.Id == model.Group.Id) + .Include(x => x.PermissionSet) + .FirstOrDefaultAsync(cancellationToken); + else + permissionSet = new Models.PermissionSet + { + AdministrationRights = model.PermissionSet?.AdministrationRights ?? AdministrationRights.None, + InstanceManagerRights = model.PermissionSet?.InstanceManagerRights ?? InstanceManagerRights.None, + }; + + return new User + { + CreatedAt = DateTimeOffset.UtcNow, + CreatedBy = AuthenticationContext.User, + Enabled = model.Enabled ?? false, + PermissionSet = permissionSet, + Group = group, + Name = model.Name, + SystemIdentifier = model.SystemIdentifier, + OAuthConnections = model + .OAuthConnections + ?.Select(x => new Models.OAuthConnection + { + Provider = x.Provider, + ExternalUserId = x.ExternalUserId, + }) + .ToList() + ?? new List(), + }; + } + + /// + /// Attempt to change the password of a given . + /// + /// The user to update. + /// The new password. + /// If this is for a new . + /// on success, an errored if is too short. + AuthorityResponse? TrySetPassword(User dbUser, string newPassword, bool newUser) + { + newPassword ??= String.Empty; + if (newPassword.Length < generalConfigurationOptions.Value.MinimumPasswordLength) + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.UserPasswordLength) + { + AdditionalData = $"Required password length: {generalConfigurationOptions.Value.MinimumPasswordLength}", + }, + HttpFailureResponse.BadRequest); + cryptographySuite.SetUserPassword(dbUser, newPassword, newUser); + return null; + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs new file mode 100644 index 0000000000..4b9fcdb401 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using GreenDonut; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Authority +{ + /// + sealed class UserGroupAuthority : AuthorityBase, IUserGroupAuthority + { + /// + /// The for the . + /// + readonly IUserGroupsDataLoader userGroupsDataLoader; + + /// + /// The of the . + /// + readonly IOptionsSnapshot generalConfigurationOptions; + + /// + /// Implements the . + /// + /// The of s to load. + /// The to load from. + /// The for the operation. + /// A resulting in a of the requested s. + [DataLoader] + public static Task> GetUserGroups( + IReadOnlyList ids, + IDatabaseContext databaseContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(ids); + ArgumentNullException.ThrowIfNull(databaseContext); + + return databaseContext + .Groups + .Where(group => ids.Contains(group.Id!.Value)) + .ToDictionaryAsync(userGroup => userGroup.Id!.Value, cancellationToken); + } + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The to use. + /// The to use. + /// The value of . + /// The value of . + public UserGroupAuthority( + IAuthenticationContext authenticationContext, + IDatabaseContext databaseContext, + ILogger logger, + IUserGroupsDataLoader userGroupsDataLoader, + IOptionsSnapshot generalConfigurationOptions) + : base( + authenticationContext, + databaseContext, + logger) + { + this.userGroupsDataLoader = userGroupsDataLoader ?? throw new ArgumentNullException(nameof(userGroupsDataLoader)); + this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + } + + /// + public async ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken) + { + if (id != AuthenticationContext.User.GroupId && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers)) + return Forbid(); + + UserGroup? userGroup; + if (includeJoins) + userGroup = await Queryable(true) + .Where(x => x.Id == id) + .FirstOrDefaultAsync(cancellationToken); + else + userGroup = await userGroupsDataLoader.LoadAsync(id, cancellationToken); + + if (userGroup == null) + return Gone(); + + return new AuthorityResponse(userGroup); + } + + /// + public ValueTask> Read() + { + var group = AuthenticationContext.User!.Group; + if (group == null) + return ValueTask.FromResult(Gone()); + + return ValueTask.FromResult(new AuthorityResponse(group)); + } + + /// + public IQueryable Queryable(bool includeJoins) + { + var queryable = DatabaseContext + .Groups + .AsQueryable(); + + if (includeJoins) + queryable = queryable + .Include(x => x.Users) + .Include(x => x.PermissionSet); + + return queryable; + } + + /// + public async ValueTask> Create(string name, Models.PermissionSet? permissionSet, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(name); + + var totalGroups = await DatabaseContext + .Groups + .AsQueryable() + .CountAsync(cancellationToken); + if (totalGroups >= generalConfigurationOptions.Value.UserGroupLimit) + return Conflict(ErrorCode.UserGroupLimitReached); + + var modelPermissionSet = new Models.PermissionSet + { + AdministrationRights = permissionSet?.AdministrationRights ?? AdministrationRights.None, + InstanceManagerRights = permissionSet?.InstanceManagerRights ?? InstanceManagerRights.None, + }; + + var dbGroup = new UserGroup + { + Name = name, + PermissionSet = modelPermissionSet, + }; + + DatabaseContext.Groups.Add(dbGroup); + await DatabaseContext.Save(cancellationToken); + Logger.LogInformation("Created new user group {groupName} ({groupId})", dbGroup.Name, dbGroup.Id); + + return new AuthorityResponse( + dbGroup, + HttpSuccessResponse.Created); + } + + /// + public async ValueTask> Update(long id, string? newName, Models.PermissionSet? newPermissionSet, CancellationToken cancellationToken) + { + var currentGroup = await DatabaseContext + .Groups + .AsQueryable() + .Where(x => x.Id == id) + .Include(x => x.PermissionSet) + .FirstOrDefaultAsync(cancellationToken); + + if (currentGroup == default) + return Gone(); + + if (newPermissionSet != null) + { + currentGroup.PermissionSet!.AdministrationRights = newPermissionSet.AdministrationRights ?? currentGroup.PermissionSet.AdministrationRights; + currentGroup.PermissionSet.InstanceManagerRights = newPermissionSet.InstanceManagerRights ?? currentGroup.PermissionSet.InstanceManagerRights; + } + + currentGroup.Name = newName ?? currentGroup.Name; + + await DatabaseContext.Save(cancellationToken); + + return new AuthorityResponse(currentGroup); + } + + /// + public async ValueTask DeleteEmpty(long id, CancellationToken cancellationToken) + { + var numDeleted = await DatabaseContext + .Groups + .AsQueryable() + .Where(x => x.Id == id && x.Users!.Count == 0) + .ExecuteDeleteAsync(cancellationToken); + + if (numDeleted > 0) + return new(); + + // find out how we failed + var groupExists = await DatabaseContext + .Groups + .AsQueryable() + .Where(x => x.Id == id) + .AnyAsync(cancellationToken); + + return new( + groupExists + ? new ErrorMessageResponse(ErrorCode.UserGroupNotEmpty) + : new ErrorMessageResponse(), + groupExists + ? HttpFailureResponse.Conflict + : HttpFailureResponse.Gone); + } + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs index ad83a80b18..3f36964a25 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs @@ -8,11 +8,13 @@ using System.Threading.Tasks; using Meebey.SmartIrc4net; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Newtonsoft.Json; using Tgstation.Server.Api.Models; using Tgstation.Server.Host.Components.Interop; +using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; @@ -42,11 +44,6 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// public override string BotMention => client.Nickname; - /// - /// The client. - /// - readonly IrcFeatures client; - /// /// Address of the server to connect to. /// @@ -57,6 +54,11 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// readonly ushort port; + /// + /// Wether or not this IRC client is to use ssl. + /// + readonly bool ssl; + /// /// IRC nickname. /// @@ -82,6 +84,21 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// readonly Dictionary queryChannelIdMap; + /// + /// The obtained from constructor, used for the CTCP version string. + /// + readonly IAssemblyInformationProvider assemblyInfo; + + /// + /// The for the . + /// + readonly IOptionsMonitor loggingConfigurationOptions; + + /// + /// The client. + /// + IrcFeatures client; + /// /// The used for . /// @@ -92,11 +109,6 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// ulong channelIdCounter; - /// - /// If we are disconnecting. - /// - bool disconnecting; - /// /// Initializes a new instance of the class. /// @@ -105,15 +117,18 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// The for the . /// The to get the from. /// The for the . + /// The value of . public IrcProvider( IJobManager jobManager, IAsyncDelayer asyncDelayer, ILogger logger, + Models.ChatBot chatBot, IAssemblyInformationProvider assemblyInformationProvider, - Models.ChatBot chatBot) + IOptionsMonitor loggingConfigurationOptions) : base(jobManager, asyncDelayer, logger, chatBot) { ArgumentNullException.ThrowIfNull(assemblyInformationProvider); + ArgumentNullException.ThrowIfNull(loggingConfigurationOptions); var builder = chatBot.CreateConnectionStringBuilder(); if (builder == null || !builder.Valid || builder is not IrcConnectionStringBuilder ircBuilder) @@ -121,33 +136,16 @@ namespace Tgstation.Server.Host.Components.Chat.Providers address = ircBuilder.Address!; port = ircBuilder.Port!.Value; + ssl = ircBuilder.UseSsl!.Value; nickname = ircBuilder.Nickname!; password = ircBuilder.Password!; passwordType = ircBuilder.PasswordType; - client = new IrcFeatures - { - SupportNonRfc = true, - CtcpUserInfo = "You are going to play. And I am going to watch. And everything will be just fine...", - AutoRejoin = true, - AutoRejoinOnKick = true, - AutoRelogin = true, - AutoRetry = false, - AutoReconnect = false, - ActiveChannelSyncing = true, - AutoNickHandling = true, - CtcpVersion = assemblyInformationProvider.VersionString, - UseSsl = ircBuilder.UseSsl!.Value, - }; - if (ircBuilder.UseSsl.Value) - client.ValidateServerCertificate = true; // dunno if it defaults to that or what + assemblyInfo = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); + this.loggingConfigurationOptions = loggingConfigurationOptions ?? throw new ArgumentNullException(nameof(loggingConfigurationOptions)); - client.OnChannelMessage += Client_OnChannelMessage; - client.OnQueryMessage += Client_OnQueryMessage; - - /*client.OnReadLine += (sender, e) => Logger.LogTrace("READ: {line}", e.Line); - client.OnWriteLine += (sender, e) => Logger.LogTrace("WRITE: {line}", e.Line);*/ + client = InstantiateClient(); channelIdMap = new Dictionary(); queryChannelIdMap = new Dictionary(); @@ -369,12 +367,15 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// protected override async ValueTask Connect(CancellationToken cancellationToken) { - disconnecting = false; cancellationToken.ThrowIfCancellationRequested(); try { await Task.Factory.StartNew( - () => client.Connect(address, port), + () => + { + client = InstantiateClient(); + client.Connect(address, port); + }, cancellationToken, DefaultIOManager.BlockingTaskCreationOptions, TaskScheduler.Current) @@ -382,70 +383,53 @@ namespace Tgstation.Server.Host.Components.Chat.Providers cancellationToken.ThrowIfCancellationRequested(); + listenTask = Task.Factory.StartNew( + () => + { + Logger.LogTrace("Starting blocking listen..."); + try + { + client.Listen(); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "IRC Main Listen Exception!"); + } + + Logger.LogTrace("Exiting listening task..."); + }, + cancellationToken, + DefaultIOManager.BlockingTaskCreationOptions, + TaskScheduler.Current); + Logger.LogTrace("Authenticating ({passwordType})...", passwordType); switch (passwordType) { case IrcPasswordType.Server: - client.Login(nickname, nickname, 0, nickname, password); + client.RfcPass(password); + await Login(client, nickname, cancellationToken); break; case IrcPasswordType.NickServ: - client.Login(nickname, nickname, 0, nickname); + await Login(client, nickname, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); client.SendMessage(SendType.Message, "NickServ", String.Format(CultureInfo.InvariantCulture, "IDENTIFY {0}", password)); break; case IrcPasswordType.Sasl: await SaslAuthenticate(cancellationToken); break; + case IrcPasswordType.Oper: + await Login(client, nickname, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + client.RfcOper(nickname, password, Priority.Critical); + break; case null: - client.Login(nickname, nickname, 0, nickname); + await Login(client, nickname, cancellationToken); break; default: throw new InvalidOperationException($"Invalid IrcPasswordType: {passwordType.Value}"); } cancellationToken.ThrowIfCancellationRequested(); - Logger.LogTrace("Processing initial messages..."); - await NonBlockingListen(cancellationToken); - - var nickCheckCompleteTcs = new TaskCompletionSource(); - using (cancellationToken.Register(() => nickCheckCompleteTcs.TrySetCanceled(cancellationToken))) - { - listenTask = Task.Factory.StartNew( - async () => - { - Logger.LogTrace("Entering nick check loop"); - while (!disconnecting && client.IsConnected && client.Nickname != nickname) - { - client.ListenOnce(true); - if (disconnecting || !client.IsConnected) - break; - await NonBlockingListen(cancellationToken); - - // ensure we have the correct nick - if (client.GetIrcUser(nickname) == null) - client.RfcNick(nickname); - } - - nickCheckCompleteTcs.TrySetResult(); - - Logger.LogTrace("Starting blocking listen..."); - try - { - client.Listen(); - } - catch (Exception ex) - { - Logger.LogWarning(ex, "IRC Main Listen Exception!"); - } - - Logger.LogTrace("Exiting listening task..."); - }, - cancellationToken, - DefaultIOManager.BlockingTaskCreationOptions, - TaskScheduler.Current); - - await nickCheckCompleteTcs.Task; - } Logger.LogTrace("Connection established!"); } @@ -487,6 +471,44 @@ namespace Tgstation.Server.Host.Components.Chat.Providers } } + /// + /// Register the client on the network. + /// + /// IRC client. + /// Nickname. + /// Cancellation token. + /// that resolves when registration has been completed. + /// If the IRC server fails to respond. + async ValueTask Login(IrcFeatures client, string nickname, CancellationToken cancellationToken) + { + var promise = new TaskCompletionSource(); + + void Callback(object? sender, EventArgs e) + { + Logger.LogTrace("IRC Registered."); + promise.TrySetResult(e); + } + + client.OnRegistered += Callback; + + client.Login(nickname, nickname, 0, nickname); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(30)); + + try + { + await promise.Task.WaitAsync(cts.Token); + client.OnRegistered -= Callback; + } + catch (OperationCanceledException) + { + if (client.IsConnected) + client.Disconnect(); + throw new JobException("Timed out waiting for IRC Registration"); + } + } + /// /// Handle an IRC message. /// @@ -667,8 +689,6 @@ namespace Tgstation.Server.Host.Components.Chat.Providers Logger.LogTrace("Hard disconnect"); - disconnecting = true; - // This call blocks permanently randomly sometimes // Frankly I don't give a shit var disconnectTask = Task.Factory.StartNew( @@ -693,5 +713,48 @@ namespace Tgstation.Server.Host.Components.Chat.Providers listenTask ?? Task.CompletedTask), AsyncDelayer.Delay(TimeSpan.FromSeconds(5), cancellationToken)); } + + /// + /// Creates a new instance of the IRC client. + /// Reusing the same client after a disconnection seems to cause issues. + /// + /// The client to use. + IrcFeatures InstantiateClient() + { + var newClient = new IrcFeatures + { + SupportNonRfc = true, + CtcpUserInfo = "You are going to play. And I am going to watch. And everything will be just fine...", + AutoRejoin = true, + AutoRejoinOnKick = true, + AutoRelogin = false, + AutoRetry = false, + AutoReconnect = false, + ActiveChannelSyncing = true, + AutoNickHandling = true, + CtcpVersion = assemblyInfo.VersionString, + UseSsl = ssl, + EnableUTF8Recode = true, + }; + if (ssl) + newClient.ValidateServerCertificate = true; // dunno if it defaults to that or what + + newClient.OnChannelMessage += Client_OnChannelMessage; + newClient.OnQueryMessage += Client_OnQueryMessage; + + if (loggingConfigurationOptions.CurrentValue.ProviderNetworkDebug) + { + newClient.OnReadLine += (sender, e) => Logger.LogTrace("READ: {line}", e.Line); + newClient.OnWriteLine += (sender, e) => Logger.LogTrace("WRITE: {line}", e.Line); + } + + newClient.OnError += (sender, e) => + { + Logger.LogError("IRC ERROR: {error}", e.ErrorMessage); + newClient.Disconnect(); + }; + + return newClient; + } } } diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/ProviderFactory.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/ProviderFactory.cs index 08dea27de1..f8b3be51d0 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/ProviderFactory.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/ProviderFactory.cs @@ -40,6 +40,11 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// readonly IOptionsMonitor generalConfigurationOptions; + /// + /// The for the . + /// + readonly IOptionsMonitor loggingConfigurationOptions; + /// /// Initializes a new instance of the class. /// @@ -48,18 +53,21 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// The value of . /// The value of . /// The value of . + /// The value of . public ProviderFactory( IJobManager jobManager, IAssemblyInformationProvider assemblyInformationProvider, IAsyncDelayer asyncDelayer, ILoggerFactory loggerFactory, - IOptionsMonitor generalConfigurationOptions) + IOptionsMonitor generalConfigurationOptions, + IOptionsMonitor loggingConfigurationOptions) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + this.loggingConfigurationOptions = loggingConfigurationOptions ?? throw new ArgumentNullException(nameof(loggingConfigurationOptions)); } /// @@ -72,8 +80,9 @@ namespace Tgstation.Server.Host.Components.Chat.Providers jobManager, asyncDelayer, loggerFactory.CreateLogger(), + settings, assemblyInformationProvider, - settings), + loggingConfigurationOptions), ChatProvider.Discord => new DiscordProvider( jobManager, asyncDelayer, diff --git a/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs b/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs index e71385439d..8c523d97d5 100644 --- a/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs +++ b/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs @@ -532,7 +532,7 @@ namespace Tgstation.Server.Host.Components.Deployment foreach (var lockManager in jobLockManagers.Values) lockManager.LogLockStats(builder); - logger.LogDebug("Periodic deployment log states report (R.e. Issue #1779):{newLine}{report}", Environment.NewLine, builder); // TODO: Reduce to trace once #1779 is fixed + logger.LogTrace("Periodic deployment log states report:{newLine}{report}", Environment.NewLine, builder); await asyncDelayer.Delay(TimeSpan.FromMinutes(10), cancellationToken); } diff --git a/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstaller.cs b/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstaller.cs index ac4c0964e9..f37c4489b1 100644 --- a/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstaller.cs +++ b/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstaller.cs @@ -251,10 +251,8 @@ namespace Tgstation.Server.Host.Components.Engine await Task.WhenAll(dirsMoveTasks.Concat(filesMoveTask)); } - var dotnetPath = await DotnetHelper.GetDotnetPath(platformIdentifier, IOManager, cancellationToken); - if (dotnetPath == null) - throw new JobException(ErrorCode.OpenDreamCantFindDotnet); - + var dotnetPath = (await DotnetHelper.GetDotnetPath(platformIdentifier, IOManager, cancellationToken)) + ?? throw new JobException(ErrorCode.OpenDreamCantFindDotnet); const string DeployDir = "tgs_deploy"; int? buildExitCode = null; await HandleExtremelyLongPathOperation( diff --git a/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs b/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs index 534daf8ebb..6bd98f2039 100644 --- a/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs @@ -29,6 +29,11 @@ namespace Tgstation.Server.Host.Configuration /// public bool Disable { get; set; } + /// + /// If Chat Providers should log their network traffic. Normally disabled because it is too noisy. + /// + public bool ProviderNetworkDebug { get; set; } + /// /// The minimum to display in logs. /// diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index bad2e6c10f..9b2b058010 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -38,12 +38,12 @@ namespace Tgstation.Server.Host.Controllers /// /// Default size of results. /// - private const ushort DefaultPageSize = 10; + public const ushort DefaultPageSize = 10; /// /// Maximum size of results. /// - private const ushort MaximumPageSize = 100; + public const ushort MaximumPageSize = 100; /// /// The for the operation. @@ -186,23 +186,6 @@ namespace Tgstation.Server.Host.Controllers /// A with an appropriate . protected new NotFoundObjectResult NotFound() => NotFound(new ErrorMessageResponse(ErrorCode.ResourceNeverPresent)); - /// - /// Generic 401 response. - /// - /// An with . - protected new ObjectResult Unauthorized() => this.StatusCode(HttpStatusCode.Unauthorized, null); - - /// - /// Generic 501 response. - /// - /// The that was thrown. - /// An with . - protected ObjectResult RequiresPosixSystemIdentity(NotImplementedException ex) - { - Logger.LogTrace(ex, "System identities not implemented!"); - return this.StatusCode(HttpStatusCode.NotImplemented, new ErrorMessageResponse(ErrorCode.RequiresPosixSystemIdentity)); - } - /// /// Strongly type calls to . /// @@ -210,13 +193,6 @@ namespace Tgstation.Server.Host.Controllers /// A with the given . protected StatusCodeResult StatusCode(HttpStatusCode statusCode) => StatusCode((int)statusCode); - /// - /// Generic 201 response with a given . - /// - /// The accompanying API payload. - /// A with the given . - protected ObjectResult Created(object payload) => StatusCode((int)HttpStatusCode.Created, payload); - /// /// 429 response for a given . /// @@ -301,7 +277,7 @@ namespace Tgstation.Server.Host.Controllers int? pageQuery, int? pageSizeQuery, CancellationToken cancellationToken) - where TModel : IApiTransformable + where TModel : ILegacyApiTransformable => PaginatedImpl( queryGenerator, resultTransformer, @@ -312,7 +288,7 @@ namespace Tgstation.Server.Host.Controllers /// /// Generates a paginated response. /// - /// The of model being generated. If different from , must implement for . + /// The of model being generated. If different from , must implement for . /// The of model being returned. /// A resulting in a resulting in the generated . /// A to transform the s after being queried. @@ -361,15 +337,15 @@ namespace Tgstation.Server.Host.Controllers else { totalResults = paginationResult.Results.Count(); - pagedResults = queriedResults.ToList(); + pagedResults = [.. queriedResults]; } ICollection finalResults; - if (typeof(TModel) == typeof(TResultModel)) - finalResults = (List)(object)pagedResults; // clearly a safe cast + if (typeof(TResultModel).IsAssignableFrom(typeof(TModel))) + finalResults = pagedResults.Cast().ToList(); // clearly a safe cast else finalResults = pagedResults - .OfType>() + .Cast>() .Select(x => x.ToApi()) .ToList(); diff --git a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs index 626a0a71b1..433ec5716f 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs @@ -5,7 +5,6 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; @@ -14,12 +13,13 @@ using Microsoft.Net.Http.Headers; using Octokit; using Tgstation.Server.Api; -using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.Components.Interop; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.GraphQL.Mutations.Payloads; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Security.OAuth; @@ -35,31 +35,11 @@ namespace Tgstation.Server.Host.Controllers [Route(Routes.ApiRoot)] public sealed class ApiRootController : ApiController { - /// - /// The for the . - /// - readonly ITokenFactory tokenFactory; - - /// - /// The for the . - /// - readonly ISystemIdentityFactory systemIdentityFactory; - - /// - /// The for the . - /// - readonly ICryptographySuite cryptographySuite; - /// /// The for the . /// readonly IAssemblyInformationProvider assemblyInformationProvider; - /// - /// The for the . - /// - readonly IIdentityCache identityCache; - /// /// The for the . /// @@ -80,6 +60,11 @@ namespace Tgstation.Server.Host.Controllers /// readonly IServerControl serverControl; + /// + /// The for the . + /// + readonly IRestAuthorityInvoker loginAuthority; + /// /// The for the . /// @@ -90,11 +75,7 @@ namespace Tgstation.Server.Host.Controllers /// /// The for the . /// The for the . - /// The value of . - /// The value of . - /// The value of . /// The value of . - /// The value of . /// The value of . /// The value of . /// The value of . @@ -102,21 +83,19 @@ namespace Tgstation.Server.Host.Controllers /// The containing the value of . /// The for the . /// The for the . + /// The value of . public ApiRootController( IDatabaseContext databaseContext, IAuthenticationContext authenticationContext, - ITokenFactory tokenFactory, - ISystemIdentityFactory systemIdentityFactory, - ICryptographySuite cryptographySuite, IAssemblyInformationProvider assemblyInformationProvider, - IIdentityCache identityCache, IOAuthProviders oAuthProviders, IPlatformIdentifier platformIdentifier, ISwarmService swarmService, IServerControl serverControl, IOptions generalConfigurationOptions, ILogger logger, - IApiHeadersProvider apiHeadersProvider) + IApiHeadersProvider apiHeadersProvider, + IRestAuthorityInvoker loginAuthority) : base( databaseContext, authenticationContext, @@ -124,16 +103,13 @@ namespace Tgstation.Server.Host.Controllers logger, false) { - this.tokenFactory = tokenFactory ?? throw new ArgumentNullException(nameof(tokenFactory)); - this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); - this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); - this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache)); this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); this.oAuthProviders = oAuthProviders ?? throw new ArgumentNullException(nameof(oAuthProviders)); this.swarmService = swarmService ?? throw new ArgumentNullException(nameof(swarmService)); this.serverControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + this.loginAuthority = loginAuthority ?? throw new ArgumentNullException(nameof(loginAuthority)); } /// @@ -202,172 +178,15 @@ namespace Tgstation.Server.Host.Controllers [HttpPost] [ProducesResponseType(typeof(TokenResponse), 200)] [ProducesResponseType(typeof(ErrorMessageResponse), 429)] -#pragma warning disable CA1506 // TODO: Decomplexify - public async ValueTask CreateToken(CancellationToken cancellationToken) + public ValueTask CreateToken(CancellationToken cancellationToken) { if (ApiHeaders == null) { Response.Headers.Add(HeaderNames.WWWAuthenticate, new StringValues($"basic realm=\"Create TGS {ApiHeaders.BearerAuthenticationScheme} token\"")); - return HeadersIssue(ApiHeadersProvider.HeadersException!); + return ValueTask.FromResult(HeadersIssue(ApiHeadersProvider.HeadersException!)); } - if (ApiHeaders.IsTokenAuthentication) - return BadRequest(new ErrorMessageResponse(ErrorCode.TokenWithToken)); - - var oAuthLogin = ApiHeaders.OAuthProvider.HasValue; - - ISystemIdentity? systemIdentity = null; - if (!oAuthLogin) - try - { - // trust the system over the database because a user's name can change while still having the same SID - systemIdentity = await systemIdentityFactory.CreateSystemIdentity(ApiHeaders.Username!, ApiHeaders.Password!, cancellationToken); - } - catch (NotImplementedException) - { - // Intentionally suppressed - } - - using (systemIdentity) - { - // Get the user from the database - IQueryable query = DatabaseContext.Users.AsQueryable(); - if (oAuthLogin) - { - var oAuthProvider = ApiHeaders.OAuthProvider!.Value; - string? externalUserId; - try - { - var validator = oAuthProviders - .GetValidator(oAuthProvider); - - if (validator == null) - return BadRequest(new ErrorMessageResponse(ErrorCode.OAuthProviderDisabled)); - - externalUserId = await validator - .ValidateResponseCode(ApiHeaders.OAuthCode!, cancellationToken); - - Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, externalUserId); - } - catch (RateLimitExceededException ex) - { - return RateLimit(ex); - } - - if (externalUserId == null) - return Unauthorized(); - - query = query.Where( - x => x.OAuthConnections!.Any( - y => y.Provider == oAuthProvider - && y.ExternalUserId == externalUserId)); - } - else - { - var canonicalUserName = Models.User.CanonicalizeName(ApiHeaders.Username!); - if (canonicalUserName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) - return Unauthorized(); - - if (systemIdentity == null) - query = query.Where(x => x.CanonicalName == canonicalUserName); - else - query = query.Where(x => x.CanonicalName == canonicalUserName || x.SystemIdentifier == systemIdentity.Uid); - } - - var users = await query - .Select(x => new Models.User - { - Id = x.Id, - PasswordHash = x.PasswordHash, - Enabled = x.Enabled, - Name = x.Name, - SystemIdentifier = x.SystemIdentifier, - }) - .ToListAsync(cancellationToken); - - // Pick the DB user first - var user = users - .OrderByDescending(dbUser => dbUser.SystemIdentifier == null) - .FirstOrDefault(); - - // No user? You're not allowed - if (user == null) - return Unauthorized(); - - // A system user may have had their name AND password changed to one in our DB... - // Or a DB user was created that had the same user/pass as a system user - // Dumb admins... - // FALLBACK TO THE DB USER HERE, DO NOT REVEAL A SYSTEM LOGIN!!! - // This of course, allows system users to discover TGS users in this (HIGHLY IMPROBABLE) case but that is not our fault - var originalHash = user.PasswordHash; - var isLikelyDbUser = originalHash != null; - bool usingSystemIdentity = systemIdentity != null && !isLikelyDbUser; - if (!oAuthLogin) - if (!usingSystemIdentity) - { - // DB User password check and update - if (!isLikelyDbUser || !cryptographySuite.CheckUserPassword(user, ApiHeaders.Password!)) - return Unauthorized(); - if (user.PasswordHash != originalHash) - { - Logger.LogDebug("User ID {userId}'s password hash needs a refresh, updating database.", user.Id); - var updatedUser = new Models.User - { - Id = user.Id, - }; - DatabaseContext.Users.Attach(updatedUser); - updatedUser.PasswordHash = user.PasswordHash; - await DatabaseContext.Save(cancellationToken); - } - } - else - { - var usernameMismatch = systemIdentity!.Username != user.Name; - if (isLikelyDbUser || usernameMismatch) - { - DatabaseContext.Users.Attach(user); - if (isLikelyDbUser) - { - // cleanup from https://github.com/tgstation/tgstation-server/issues/1528 - Logger.LogDebug("System user ID {userId}'s PasswordHash is polluted, updating database.", user.Id); - user.PasswordHash = null; - user.LastPasswordUpdate = DateTimeOffset.UtcNow; - } - - if (usernameMismatch) - { - // System identity username change update - Logger.LogDebug("User ID {userId}'s system identity needs a refresh, updating database.", user.Id); - user.Name = systemIdentity.Username; - user.CanonicalName = Models.User.CanonicalizeName(user.Name); - } - - await DatabaseContext.Save(cancellationToken); - } - } - - // Now that the bookeeping is done, tell them to fuck off if necessary - if (!user.Enabled!.Value) - { - Logger.LogTrace("Not logging in disabled user {userId}.", user.Id); - return Forbid(); - } - - var token = tokenFactory.CreateToken(user, oAuthLogin); - if (usingSystemIdentity) - { - // expire the identity slightly after the auth token in case of lag - var identExpiry = token.ParseJwt().ValidTo; - identExpiry += tokenFactory.ValidationParameters.ClockSkew; - identExpiry += TimeSpan.FromSeconds(15); - await identityCache.CacheSystemIdentity(user, systemIdentity!, identExpiry); - } - - Logger.LogDebug("Successfully logged in user {userId}!", user.Id); - - return Json(token); - } + return loginAuthority.InvokeTransformable(this, authority => authority.AttemptLogin(cancellationToken)); } -#pragma warning restore CA1506 } } diff --git a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs index 8232be095c..f060946784 100644 --- a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs +++ b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs @@ -14,6 +14,7 @@ using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; @@ -266,7 +267,7 @@ namespace Tgstation.Server.Host.Controllers return result.Value ? Json(resultModel) - : Created(resultModel); + : this.Created(resultModel); }); } catch (IOException e) diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index 4b25eac143..bada9ace1b 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -258,7 +258,7 @@ namespace Tgstation.Server.Host.Controllers var api = newInstance.ToApi(); api.Accessible = true; // instances are always accessible by their creator - return attached ? Json(api) : Created(api); + return attached ? Json(api) : this.Created(api); } /// diff --git a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs index 58b0170ad4..5b4ac2a9c4 100644 --- a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs @@ -123,7 +123,7 @@ namespace Tgstation.Server.Host.Controllers // needs to be set for next call dbUser.PermissionSet = existingPermissionSet; await permissionsUpdateNotifyee.InstancePermissionSetCreated(dbUser, cancellationToken); - return Created(dbUser.ToApi()); + return this.Created(dbUser.ToApi()); } #pragma warning restore CA1506 diff --git a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs index 6b4a6f3175..533aec872d 100644 --- a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs +++ b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs @@ -205,7 +205,7 @@ namespace Tgstation.Server.Host.Controllers api.Reference = model.Reference; api.ActiveJob = job.ToApi(); - return Created(api); + return this.Created(api); }); } @@ -326,7 +326,7 @@ namespace Tgstation.Server.Host.Controllers { // user may have fucked with the repo manually, do what we can await DatabaseContext.Save(cancellationToken); - return Created(api); + return this.Created(api); } return Json(api); diff --git a/src/Tgstation.Server.Host/Controllers/RootController.cs b/src/Tgstation.Server.Host/Controllers/RootController.cs index 577fecf1c6..fcf2bd239b 100644 --- a/src/Tgstation.Server.Host/Controllers/RootController.cs +++ b/src/Tgstation.Server.Host/Controllers/RootController.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Tgstation.Server.Api; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.System; @@ -62,6 +63,11 @@ namespace Tgstation.Server.Host.Controllers /// readonly ControlPanelConfiguration controlPanelConfiguration; + /// + /// The for the . + /// + readonly InternalConfiguration internalConfiguration; + /// /// Gets a giving the and action names for a given . /// @@ -92,13 +98,15 @@ namespace Tgstation.Server.Host.Controllers /// The value of . /// The containing the value of . /// The containing the value of . + /// The containing the value of . public RootController( IAssemblyInformationProvider assemblyInformationProvider, IPlatformIdentifier platformIdentifier, IWebHostEnvironment hostEnvironment, ILogger logger, IOptions generalConfigurationOptions, - IOptions controlPanelConfigurationOptions) + IOptions controlPanelConfigurationOptions, + IOptionsSnapshot internalConfigurationOptions) { this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); @@ -106,6 +114,7 @@ namespace Tgstation.Server.Host.Controllers this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); controlPanelConfiguration = controlPanelConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(controlPanelConfigurationOptions)); + internalConfiguration = internalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(internalConfigurationOptions)); } /// @@ -120,19 +129,26 @@ namespace Tgstation.Server.Host.Controllers var apiDocsEnabled = generalConfiguration.HostApiDocumentation; var controlPanelRoute = $"{ControlPanelController.ControlPanelRoute.TrimStart('/')}/"; - if (panelEnabled ^ apiDocsEnabled) - if (panelEnabled) - return Redirect(controlPanelRoute); - else - return Redirect(SwaggerConfiguration.DocumentationSiteRouteExtension); + if (panelEnabled && !apiDocsEnabled) + return Redirect(controlPanelRoute); - Dictionary? links; - if (panelEnabled) - links = new Dictionary() + Dictionary? links = null; + + if (panelEnabled || apiDocsEnabled) + { + links = new Dictionary(); + + if (panelEnabled) + links.Add("Web Control Panel", controlPanelRoute); + + if (apiDocsEnabled) { - { "Web Control Panel", controlPanelRoute }, - { "API Documentation", SwaggerConfiguration.DocumentationSiteRouteExtension }, - }; + if (internalConfiguration.EnableGraphQL) + links.Add("GraphQL API Documentation", Routes.GraphQL); + + links.Add("REST API Documentation", SwaggerConfiguration.DocumentationSiteRouteExtension); + } + } else links = null; diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index 68bccfa169..f55218ef64 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -1,23 +1,19 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; -using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; -using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Utils; @@ -31,44 +27,23 @@ namespace Tgstation.Server.Host.Controllers public sealed class UserController : ApiController { /// - /// The for the . + /// The for the . /// - readonly ISystemIdentityFactory systemIdentityFactory; - - /// - /// The for the . - /// - readonly ICryptographySuite cryptographySuite; - - /// - /// The for the . - /// - readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee; - - /// - /// The for the . - /// - readonly GeneralConfiguration generalConfiguration; + readonly IRestAuthorityInvoker userAuthority; /// /// Initializes a new instance of the class. /// /// The for the . /// The for the . - /// The value of . - /// The value of . - /// The value of . + /// The value of . /// The for the . - /// The containing the value of . /// The for the . public UserController( IDatabaseContext databaseContext, IAuthenticationContext authenticationContext, - ISystemIdentityFactory systemIdentityFactory, - ICryptographySuite cryptographySuite, - IPermissionsUpdateNotifyee permissionsUpdateNotifyee, + IRestAuthorityInvoker userAuthority, ILogger logger, - IOptions generalConfigurationOptions, IApiHeadersProvider apiHeaders) : base( databaseContext, @@ -77,10 +52,7 @@ namespace Tgstation.Server.Host.Controllers logger, true) { - this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); - this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); - this.permissionsUpdateNotifyee = permissionsUpdateNotifyee ?? throw new ArgumentNullException(nameof(permissionsUpdateNotifyee)); - generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + this.userAuthority = userAuthority ?? throw new ArgumentNullException(nameof(userAuthority)); } /// @@ -92,81 +64,15 @@ namespace Tgstation.Server.Host.Controllers /// created successfully. /// The requested system identifier could not be found. [HttpPut] - [TgsAuthorize(AdministrationRights.WriteUsers)] + [TgsRestAuthorize(nameof(IUserAuthority.Create))] [ProducesResponseType(typeof(UserResponse), 201)] -#pragma warning disable CA1502, CA1506 - public async ValueTask Create([FromBody] UserCreateRequest model, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(model); - - if (model.OAuthConnections?.Any(x => x == null) == true) - return BadRequest(new ErrorMessageResponse(ErrorCode.ModelValidationFailure)); - - if ((model.Password != null && model.SystemIdentifier != null) - || (model.Password == null && model.SystemIdentifier == null && (model.OAuthConnections?.Count > 0) != true)) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserMismatchPasswordSid)); - - if (model.Group != null && model.PermissionSet != null) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserGroupAndPermissionSet)); - - model.Name = model.Name?.Trim(); - if (model.Name?.Length == 0) - model.Name = null; - - if (!(model.Name == null ^ model.SystemIdentifier == null)) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserMismatchNameSid)); - - var fail = CheckValidName(model, true); - if (fail != null) - return fail; - - var totalUsers = await DatabaseContext - .Users - .AsQueryable() - .CountAsync(cancellationToken); - if (totalUsers >= generalConfiguration.UserLimit) - return Conflict(new ErrorMessageResponse(ErrorCode.UserLimitReached)); - - var dbUser = await CreateNewUserFromModel(model, cancellationToken); - if (dbUser == null) - return this.Gone(); - - if (model.SystemIdentifier != null) - try - { - using var sysIdentity = await systemIdentityFactory.CreateSystemIdentity(dbUser, cancellationToken); - if (sysIdentity == null) - return this.Gone(); - dbUser.Name = sysIdentity.Username; - dbUser.SystemIdentifier = sysIdentity.Uid; - } - catch (NotImplementedException ex) - { - return RequiresPosixSystemIdentity(ex); - } - else if (!(model.Password?.Length == 0 && (model.OAuthConnections?.Count > 0) == true)) - { - var result = TrySetPassword(dbUser, model.Password!, true); - if (result != null) - return result; - } - - dbUser.CanonicalName = Models.User.CanonicalizeName(dbUser.Name!); - - DatabaseContext.Users.Add(dbUser); - - await DatabaseContext.Save(cancellationToken); - - Logger.LogInformation("Created new user {name} ({id})", dbUser.Name, dbUser.Id); - - return Created(dbUser.ToApi()); - } -#pragma warning restore CA1502, CA1506 + public ValueTask Create([FromBody] UserCreateRequest model, CancellationToken cancellationToken) + => userAuthority.InvokeTransformable(this, authority => authority.Create(model, null, cancellationToken)); /// /// Update a . /// - /// The to update. + /// The . /// The for the operation. /// A resulting in the of the operation. /// updated successfully. @@ -174,181 +80,25 @@ namespace Tgstation.Server.Host.Controllers /// Requested does not exist. /// Requested does not exist. [HttpPost] - [TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnOAuthConnections)] + [TgsRestAuthorize(nameof(IUserAuthority.Update))] [ProducesResponseType(typeof(UserResponse), 200)] [ProducesResponseType(204)] [ProducesResponseType(typeof(ErrorMessageResponse), 404)] [ProducesResponseType(typeof(ErrorMessageResponse), 410)] -#pragma warning disable CA1502 // TODO: Decomplexify -#pragma warning disable CA1506 - public async ValueTask Update([FromBody] UserUpdateRequest model, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(model); - - if (!model.Id.HasValue || model.OAuthConnections?.Any(x => x == null) == true) - return BadRequest(new ErrorMessageResponse(ErrorCode.ModelValidationFailure)); - - if (model.Group != null && model.PermissionSet != null) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserGroupAndPermissionSet)); - - var callerAdministrationRights = (AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration); - var canEditAllUsers = callerAdministrationRights.HasFlag(AdministrationRights.WriteUsers); - var passwordEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnPassword); - var oAuthEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnOAuthConnections); - - var originalUser = !canEditAllUsers - ? AuthenticationContext.User - : await DatabaseContext - .Users - .AsQueryable() - .Where(x => x.Id == model.Id) - .Include(x => x.CreatedBy) - .Include(x => x.OAuthConnections) - .Include(x => x.Group!) - .ThenInclude(x => x.PermissionSet) - .Include(x => x.PermissionSet) - .FirstOrDefaultAsync(cancellationToken); - - if (originalUser == default) - return NotFound(); - - if (originalUser.CanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) - return Forbid(); - - // Ensure they are only trying to edit things they have perms for (system identity change will trigger a bad request) - if ((!canEditAllUsers - && (model.Id != originalUser.Id - || model.Enabled.HasValue - || model.Group != null - || model.PermissionSet != null - || model.Name != null)) - || (!passwordEdit && model.Password != null) - || (!oAuthEdit && model.OAuthConnections != null)) - return Forbid(); - - var originalUserHasSid = originalUser.SystemIdentifier != null; - if (originalUserHasSid && originalUser.PasswordHash != null) - { - // cleanup from https://github.com/tgstation/tgstation-server/issues/1528 - Logger.LogDebug("System user ID {userId}'s PasswordHash is polluted, updating database.", originalUser.Id); - originalUser.PasswordHash = null; - originalUser.LastPasswordUpdate = DateTimeOffset.UtcNow; - } - - if (model.SystemIdentifier != null && model.SystemIdentifier != originalUser.SystemIdentifier) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserSidChange)); - - if (model.Password != null) - { - if (originalUserHasSid) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserMismatchPasswordSid)); - - var result = TrySetPassword(originalUser, model.Password, false); - if (result != null) - return result; - } - - if (model.Name != null && Models.User.CanonicalizeName(model.Name) != originalUser.CanonicalName) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserNameChange)); - - bool userWasDisabled; - if (model.Enabled.HasValue) - { - userWasDisabled = originalUser.Require(x => x.Enabled) && !model.Enabled.Value; - if (userWasDisabled) - originalUser.LastPasswordUpdate = DateTimeOffset.UtcNow; - - originalUser.Enabled = model.Enabled.Value; - } - else - userWasDisabled = false; - - if (model.OAuthConnections != null - && (model.OAuthConnections.Count != originalUser.OAuthConnections!.Count - || !model.OAuthConnections.All(x => originalUser.OAuthConnections.Any(y => y.Provider == x.Provider && y.ExternalUserId == x.ExternalUserId)))) - { - if (originalUser.CanonicalName == Models.User.CanonicalizeName(DefaultCredentials.AdminUserName)) - return BadRequest(new ErrorMessageResponse(ErrorCode.AdminUserCannotOAuth)); - - if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null) - return BadRequest(new ErrorMessageResponse(ErrorCode.CannotRemoveLastAuthenticationOption)); - - originalUser.OAuthConnections.Clear(); - foreach (var updatedConnection in model.OAuthConnections) - originalUser.OAuthConnections.Add(new Models.OAuthConnection - { - Provider = updatedConnection.Provider, - ExternalUserId = updatedConnection.ExternalUserId, - }); - } - - if (model.Group != null) - { - originalUser.Group = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == model.Group.Id) - .Include(x => x.PermissionSet) - .FirstOrDefaultAsync(cancellationToken); - - if (originalUser.Group == default) - return this.Gone(); - - DatabaseContext.Groups.Attach(originalUser.Group); - if (originalUser.PermissionSet != null) - { - Logger.LogInformation("Deleting permission set {permissionSetId}...", originalUser.PermissionSet.Id); - DatabaseContext.PermissionSets.Remove(originalUser.PermissionSet); - originalUser.PermissionSet = null; - } - } - else if (model.PermissionSet != null) - { - if (originalUser.PermissionSet == null) - { - Logger.LogTrace("Creating new permission set..."); - originalUser.PermissionSet = new Models.PermissionSet(); - } - - originalUser.PermissionSet.AdministrationRights = model.PermissionSet.AdministrationRights ?? AdministrationRights.None; - originalUser.PermissionSet.InstanceManagerRights = model.PermissionSet.InstanceManagerRights ?? InstanceManagerRights.None; - - originalUser.Group = null; - originalUser.GroupId = null; - } - - var fail = CheckValidName(model, false); - if (fail != null) - return fail; - - originalUser.Name = model.Name ?? originalUser.Name; - - await DatabaseContext.Save(cancellationToken); - - Logger.LogInformation("Updated user {userName} ({userId})", originalUser.Name, originalUser.Id); - - if (userWasDisabled) - await permissionsUpdateNotifyee.UserDisabled(originalUser, cancellationToken); - - // return id only if not a self update and cannot read users - var canReadBack = AuthenticationContext.User.Id == originalUser.Id - || callerAdministrationRights.HasFlag(AdministrationRights.ReadUsers); - return canReadBack - ? Json(originalUser.ToApi()) - : NoContent(); - } -#pragma warning restore CA1506 -#pragma warning restore CA1502 + public ValueTask Update([FromBody] UserUpdateRequest model, CancellationToken cancellationToken) + => userAuthority.InvokeTransformable(this, authority => authority.Update(model, cancellationToken)); /// /// Get information about the current . /// - /// The of the operation. + /// The for the operation. + /// A resulting in the of the operation. /// The was retrieved successfully. [HttpGet] - [TgsAuthorize] + [TgsRestAuthorize(nameof(IUserAuthority.Read))] [ProducesResponseType(typeof(UserResponse), 200)] - public IActionResult Read() => Json(AuthenticationContext.User.ToApi()); + public ValueTask Read(CancellationToken cancellationToken) + => userAuthority.InvokeTransformable(this, authority => authority.Read(cancellationToken)); /// /// List all s in the server. @@ -359,21 +109,14 @@ namespace Tgstation.Server.Host.Controllers /// A resulting in the of the operation. /// Retrieved s successfully. [HttpGet(Routes.List)] - [TgsAuthorize(AdministrationRights.ReadUsers)] + [TgsRestAuthorize(nameof(IUserAuthority.Queryable))] [ProducesResponseType(typeof(PaginatedResponse), 200)] public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => Paginated( () => ValueTask.FromResult( new PaginatableResult( - DatabaseContext - .Users - .AsQueryable() - .Where(x => x.CanonicalName != Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) - .Include(x => x.CreatedBy) - .Include(x => x.PermissionSet) - .Include(x => x.OAuthConnections) - .Include(x => x.Group!) - .ThenInclude(x => x.PermissionSet) + userAuthority.InvokeQueryable( + authority => authority.Queryable(true)) .OrderBy(x => x.Id))), null, page, @@ -395,109 +138,14 @@ namespace Tgstation.Server.Host.Controllers public async ValueTask GetId(long id, CancellationToken cancellationToken) { if (id == AuthenticationContext.User.Id) - return Read(); + return await Read(cancellationToken); if (!((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers)) return Forbid(); - var user = await DatabaseContext.Users - .AsQueryable() - .Where(x => x.Id == id) - .Include(x => x.CreatedBy) - .Include(x => x.OAuthConnections) - .Include(x => x.Group!) - .ThenInclude(x => x.PermissionSet) - .Include(x => x.PermissionSet) - .FirstOrDefaultAsync(cancellationToken); - if (user == default) - return NotFound(); - - if (user.CanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) - return Forbid(); - - return Json(user.ToApi()); - } - - /// - /// Creates a new from a given . - /// - /// The to use as a template. - /// The for the operation. - /// A resulting in a new on success, if the requested did not exist. - async ValueTask CreateNewUserFromModel(Api.Models.Internal.UserApiBase model, CancellationToken cancellationToken) - { - Models.PermissionSet? permissionSet = null; - UserGroup? group = null; - if (model.Group != null) - group = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == model.Group.Id) - .Include(x => x.PermissionSet) - .FirstOrDefaultAsync(cancellationToken); - else - permissionSet = new Models.PermissionSet - { - AdministrationRights = model.PermissionSet?.AdministrationRights ?? AdministrationRights.None, - InstanceManagerRights = model.PermissionSet?.InstanceManagerRights ?? InstanceManagerRights.None, - }; - - return new User - { - CreatedAt = DateTimeOffset.UtcNow, - CreatedBy = AuthenticationContext.User, - Enabled = model.Enabled ?? false, - PermissionSet = permissionSet, - Group = group, - Name = model.Name, - SystemIdentifier = model.SystemIdentifier, - OAuthConnections = model - .OAuthConnections - ?.Select(x => new Models.OAuthConnection - { - Provider = x.Provider, - ExternalUserId = x.ExternalUserId, - }) - .ToList() - ?? new List(), - }; - } - - /// - /// Check if a given has a valid specified. - /// - /// The to check. - /// If this is a new . - /// if is valid, a otherwise. - BadRequestObjectResult? CheckValidName(UserUpdateRequest model, bool newUser) - { - var userInvalidWithNullName = newUser && model.Name == null && model.SystemIdentifier == null; - if (userInvalidWithNullName || (model.Name != null && String.IsNullOrWhiteSpace(model.Name))) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserMissingName)); - - model.Name = model.Name?.Trim(); - if (model.Name != null && model.Name.Contains(':', StringComparison.InvariantCulture)) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserColonInName)); - return null; - } - - /// - /// Attempt to change the password of a given . - /// - /// The user to update. - /// The new password. - /// If this is for a new . - /// on success, if is too short. - BadRequestObjectResult? TrySetPassword(User dbUser, string newPassword, bool newUser) - { - newPassword ??= String.Empty; - if (newPassword.Length < generalConfiguration.MinimumPasswordLength) - return BadRequest(new ErrorMessageResponse(ErrorCode.UserPasswordLength) - { - AdditionalData = $"Required password length: {generalConfiguration.MinimumPasswordLength}", - }); - cryptographySuite.SetUserPassword(dbUser, newPassword, newUser); - return null; + return await userAuthority.InvokeTransformable( + this, + authority => authority.GetId(id, true, false, cancellationToken)); } } } diff --git a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs index 838b7a1bcf..3ff0f0bcac 100644 --- a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs @@ -4,19 +4,16 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; -using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; -using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Utils; @@ -30,24 +27,24 @@ namespace Tgstation.Server.Host.Controllers public class UserGroupController : ApiController { /// - /// The for the . + /// The for the . /// - readonly GeneralConfiguration generalConfiguration; + readonly IRestAuthorityInvoker userGroupAuthority; /// /// Initializes a new instance of the class. /// /// The for the . /// The for the . - /// The containing the value of . - /// The for the . /// The for the . + /// The for the . + /// The value of . public UserGroupController( IDatabaseContext databaseContext, IAuthenticationContext authenticationContext, - IOptions generalConfigurationOptions, + IApiHeadersProvider apiHeaders, ILogger logger, - IApiHeadersProvider apiHeaders) + IRestAuthorityInvoker userGroupAuthority) : base( databaseContext, authenticationContext, @@ -55,9 +52,23 @@ namespace Tgstation.Server.Host.Controllers logger, true) { - generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + this.userGroupAuthority = userGroupAuthority ?? throw new ArgumentNullException(nameof(userGroupAuthority)); } + /// + /// Transform a into a . + /// + /// The to transform. + /// The transformed . + static Models.PermissionSet? TransformApiPermissionSet(Api.Models.PermissionSet? permissionSet) + => permissionSet != null + ? new Models.PermissionSet + { + InstanceManagerRights = permissionSet?.InstanceManagerRights, + AdministrationRights = permissionSet?.AdministrationRights, + } + : null; + /// /// Create a new . /// @@ -75,30 +86,12 @@ namespace Tgstation.Server.Host.Controllers if (model.Name == null) return BadRequest(new ErrorMessageResponse(ErrorCode.ModelValidationFailure)); - var totalGroups = await DatabaseContext - .Groups - .AsQueryable() - .CountAsync(cancellationToken); - if (totalGroups >= generalConfiguration.UserGroupLimit) - return Conflict(new ErrorMessageResponse(ErrorCode.UserGroupLimitReached)); - - var permissionSet = new Models.PermissionSet - { - AdministrationRights = model.PermissionSet?.AdministrationRights ?? AdministrationRights.None, - InstanceManagerRights = model.PermissionSet?.InstanceManagerRights ?? InstanceManagerRights.None, - }; - - var dbGroup = new UserGroup - { - Name = model.Name, - PermissionSet = permissionSet, - }; - - DatabaseContext.Groups.Add(dbGroup); - await DatabaseContext.Save(cancellationToken); - Logger.LogInformation("Created new user group {groupName} ({groupId})", dbGroup.Name, dbGroup.Id); - - return Created(dbGroup.ToApi(true)); + return await userGroupAuthority.InvokeTransformable( + this, + authority => authority.Create( + model.Name, + TransformApiPermissionSet(model.PermissionSet), + cancellationToken)); } /// @@ -112,38 +105,17 @@ namespace Tgstation.Server.Host.Controllers [HttpPost] [TgsAuthorize(AdministrationRights.WriteUsers)] [ProducesResponseType(typeof(UserGroupResponse), 200)] - public async ValueTask Update([FromBody] UserGroupUpdateRequest model, CancellationToken cancellationToken) + public ValueTask Update([FromBody] UserGroupUpdateRequest model, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(model); - var currentGroup = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == model.Id) - .Include(x => x.PermissionSet) - .Include(x => x.Users) - .FirstOrDefaultAsync(cancellationToken); - - if (currentGroup == default) - return this.Gone(); - - if (model.PermissionSet != null) - { - currentGroup.PermissionSet!.AdministrationRights = model.PermissionSet.AdministrationRights ?? currentGroup.PermissionSet.AdministrationRights; - currentGroup.PermissionSet.InstanceManagerRights = model.PermissionSet.InstanceManagerRights ?? currentGroup.PermissionSet.InstanceManagerRights; - } - - currentGroup.Name = model.Name ?? currentGroup.Name; - - await DatabaseContext.Save(cancellationToken); - - if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.ReadUsers)) - return Json(new UserGroupResponse - { - Id = currentGroup.Id, - }); - - return Json(currentGroup.ToApi(true)); + return userGroupAuthority.InvokeTransformable( + this, + authority => authority.Update( + model.Require(x => x.Id), + model.Name, + TransformApiPermissionSet(model.PermissionSet), + cancellationToken)); } /// @@ -155,23 +127,11 @@ namespace Tgstation.Server.Host.Controllers /// Retrieve successfully. /// The requested does not currently exist. [HttpGet("{id}")] - [TgsAuthorize(AdministrationRights.ReadUsers)] + [TgsRestAuthorize(nameof(IUserGroupAuthority.GetId))] [ProducesResponseType(typeof(UserGroupResponse), 200)] [ProducesResponseType(typeof(ErrorMessageResponse), 410)] - public async ValueTask GetId(long id, CancellationToken cancellationToken) - { - // this functions as userId - var group = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == id) - .Include(x => x.Users) - .Include(x => x.PermissionSet) - .FirstOrDefaultAsync(cancellationToken); - if (group == default) - return this.Gone(); - return Json(group.ToApi(true)); - } + public ValueTask GetId(long id, CancellationToken cancellationToken) + => userGroupAuthority.InvokeTransformable(this, authority => authority.GetId(id, true, cancellationToken)); /// /// Lists all s. @@ -182,17 +142,14 @@ namespace Tgstation.Server.Host.Controllers /// A resulting in the of the request. /// Retrieved s successfully. [HttpGet(Routes.List)] - [TgsAuthorize(AdministrationRights.ReadUsers)] + [TgsRestAuthorize(nameof(IUserGroupAuthority.Queryable))] [ProducesResponseType(typeof(PaginatedResponse), 200)] public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => Paginated( () => ValueTask.FromResult( new PaginatableResult( - DatabaseContext - .Groups - .AsQueryable() - .Include(x => x.Users) - .Include(x => x.PermissionSet) + userGroupAuthority + .InvokeQueryable(authority => authority.Queryable(true)) .OrderBy(x => x.Id))), null, page, @@ -213,27 +170,9 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(204)] [ProducesResponseType(typeof(ErrorMessageResponse), 409)] [ProducesResponseType(typeof(ErrorMessageResponse), 410)] - public async ValueTask Delete(long id, CancellationToken cancellationToken) - { - var numDeleted = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == id && x.Users!.Count == 0) - .ExecuteDeleteAsync(cancellationToken); - - if (numDeleted > 0) - return NoContent(); - - // find out how we failed - var groupExists = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == id) - .AnyAsync(cancellationToken); - - return groupExists - ? Conflict(new ErrorMessageResponse(ErrorCode.UserGroupNotEmpty)) - : this.Gone(); - } + public ValueTask Delete(long id, CancellationToken cancellationToken) +#pragma warning disable API1001 // The response type is RIGHT THERE ^^^ + => userGroupAuthority.Invoke(this, authority => authority.DeleteEmpty(id, cancellationToken)); +#pragma warning restore API1001 } } diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 0730eabd06..429cbc8989 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -8,6 +8,8 @@ using Cyberboss.AspNetCore.AsyncInitializer; using Elastic.CommonSchema.Serilog; +using HotChocolate.AspNetCore; +using HotChocolate.Subscriptions; using HotChocolate.Types; using Microsoft.AspNetCore.Authentication; @@ -37,6 +39,8 @@ using Tgstation.Server.Api; using Tgstation.Server.Api.Hubs; using Tgstation.Server.Api.Models; using Tgstation.Server.Common.Http; +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Components.Chat; using Tgstation.Server.Host.Components.Deployment.Remote; @@ -53,7 +57,10 @@ using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.GraphQL; -using Tgstation.Server.Host.GraphQL.Types.Scalars; +using Tgstation.Server.Host.GraphQL.Interceptors; +using Tgstation.Server.Host.GraphQL.Scalars; +using Tgstation.Server.Host.GraphQL.Subscriptions; +using Tgstation.Server.Host.GraphQL.Types; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Properties; @@ -288,13 +295,54 @@ namespace Tgstation.Server.Host.Core services.AddSingleton(); // configure graphql - if (postSetupServices.InternalConfiguration.EnableGraphQL) - services - .AddGraphQLServer() - .AddAuthorization() - .AddType() - .BindRuntimeType() - .AddQueryType(); + services + .AddScoped() + .AddGraphQLServer() + .AddAuthorization() + .ModifyOptions(options => + { + options.EnsureAllNodesCanBeResolved = true; + options.EnableFlagEnums = true; + }) +#if DEBUG + .ModifyCostOptions(options => + { + options.EnforceCostLimits = false; + }) +#endif + .AddMutationConventions() + .AddInMemorySubscriptions( + new SubscriptionOptions + { + TopicBufferCapacity = 1024, // mainly so high for tests, not possible to DoS the server without authentication and some other access to generate messages + }) + .AddGlobalObjectIdentification() + .AddQueryFieldToMutationPayloads() + .ModifyOptions(options => + { + options.EnableDefer = true; + }) + .ModifyPagingOptions(pagingOptions => + { + pagingOptions.IncludeTotalCount = true; + pagingOptions.RequirePagingBoundaries = false; + pagingOptions.DefaultPageSize = ApiController.DefaultPageSize; + pagingOptions.MaxPageSize = ApiController.MaximumPageSize; + }) + .AddFiltering() + .AddSorting() + .AddHostTypes() + .AddErrorFilter() + .AddType() + .AddType() + .AddType() + .AddType() + .AddType() + .BindRuntimeType() + .TryAddTypeInterceptor() + .AddQueryType() + .AddMutationType() + .AddSubscriptionType(); void AddTypedContext() where TContext : DatabaseContext @@ -343,6 +391,7 @@ namespace Tgstation.Server.Host.Core services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton, PasswordHasher>(); // configure platform specific services @@ -431,6 +480,14 @@ namespace Tgstation.Server.Host.Core services.AddSingleton(); services.AddSingleton(); + // configure authorities + services.AddScoped(typeof(IRestAuthorityInvoker<>), typeof(RestAuthorityInvoker<>)); + services.AddScoped(typeof(IGraphQLAuthorityInvoker<>), typeof(GraphQLAuthorityInvoker<>)); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // configure misc services services.AddSingleton(); services.AddSingleton(); @@ -616,7 +673,16 @@ namespace Tgstation.Server.Host.Core if (internalConfiguration.EnableGraphQL) { logger.LogWarning("Enabling GraphQL. This API is experimental and breaking changes may occur at any time!"); - endpoints.MapGraphQL(Routes.GraphQL); + var gqlOptions = new GraphQLServerOptions + { + EnableBatching = true, + }; + + gqlOptions.Tool.Enable = generalConfiguration.HostApiDocumentation; + + endpoints + .MapGraphQL(Routes.GraphQL) + .WithOptions(gqlOptions); } }); @@ -643,7 +709,7 @@ namespace Tgstation.Server.Host.Core services.AddHttpContextAccessor(); services.AddScoped(); services.AddScoped(); - services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); // what if you // wanted to just do this: @@ -684,6 +750,15 @@ namespace Tgstation.Server.Host.Core return Task.CompletedTask; }, + OnTokenValidated = context => context + .HttpContext + .RequestServices + .GetRequiredService() + .ValidateToken( + context, + context + .HttpContext + .RequestAborted), }; }); } diff --git a/src/Tgstation.Server.Host/Database/DatabaseContext.cs b/src/Tgstation.Server.Host/Database/DatabaseContext.cs index d79bb709f9..3d4bd73f0f 100644 --- a/src/Tgstation.Server.Host/Database/DatabaseContext.cs +++ b/src/Tgstation.Server.Host/Database/DatabaseContext.cs @@ -263,7 +263,7 @@ namespace Tgstation.Server.Host.Database ConfigureMethodName, BindingFlags.Public | BindingFlags.Static) ?? throw new InvalidOperationException($"Context type {typeof(TDatabaseContext).FullName} missing static {ConfigureMethodName} function!"); - return (optionsBuilder, config) => configureFunction.Invoke(null, new object[] { optionsBuilder, config }); + return (optionsBuilder, config) => configureFunction.Invoke(null, [optionsBuilder, config]); } /// diff --git a/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs b/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs index 637484f5bd..9da8c4578a 100644 --- a/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs +++ b/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs @@ -14,6 +14,7 @@ using Serilog.Context; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Properties; using Tgstation.Server.Host.System; using Tgstation.Server.Host.Utils; @@ -130,7 +131,9 @@ namespace Tgstation.Server.Host.Extensions applicationBuilder.Use(async (context, next) => { var apiHeadersProvider = context.RequestServices.GetRequiredService(); - if (apiHeadersProvider.ApiHeaders?.Compatible() == false) + if (apiHeadersProvider.ApiHeaders?.Compatible( + Version.Parse( + MasterVersionsAttribute.Instance.RawGraphQLVersion)) == false) { await new BadRequestObjectResult( new ErrorMessageResponse(ErrorCode.ApiMismatch)) diff --git a/src/Tgstation.Server.Host/Extensions/ControllerBaseExtensions.cs b/src/Tgstation.Server.Host/Extensions/ControllerBaseExtensions.cs index 818b0c44b5..dad19dd6f1 100644 --- a/src/Tgstation.Server.Host/Extensions/ControllerBaseExtensions.cs +++ b/src/Tgstation.Server.Host/Extensions/ControllerBaseExtensions.cs @@ -19,6 +19,21 @@ namespace Tgstation.Server.Host.Extensions /// static class ControllerBaseExtensions { + /// + /// Generic 201 response with a given . + /// + /// The the request is coming from. + /// The accompanying API payload. + /// A with the given . + public static ObjectResult Created(this ControllerBase controller, object payload) => controller.StatusCode(HttpStatusCode.Created, payload); + + /// + /// Generic 401 response. + /// + /// The the request is coming from. + /// An with . + public static ObjectResult Unauthorized(this ControllerBase controller) => controller.StatusCode(HttpStatusCode.Unauthorized, null); + /// /// Generic 410 response. /// diff --git a/src/Tgstation.Server.Host/Extensions/TopicClientExtensions.cs b/src/Tgstation.Server.Host/Extensions/TopicClientExtensions.cs index 55ead2144a..8d2b6b6024 100644 --- a/src/Tgstation.Server.Host/Extensions/TopicClientExtensions.cs +++ b/src/Tgstation.Server.Host/Extensions/TopicClientExtensions.cs @@ -60,7 +60,7 @@ namespace Tgstation.Server.Host.Extensions logger.LogTrace("End topic request #{requestId}", localRequestId); return byondResponse; } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (Exception ex) when (ex is not OperationCanceledException && ex is not ArgumentException) { logger.LogWarning(ex, "SendTopic exception!{retryDetails}", priority ? $" {i} attempts remaining." : String.Empty); diff --git a/src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs b/src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs new file mode 100644 index 0000000000..38315ca79e --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs @@ -0,0 +1,46 @@ +using System; + +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Response; + +#pragma warning disable CA1032 // Shitty unneeded additional Exception constructors + +namespace Tgstation.Server.Host.GraphQL +{ + /// + /// representing s. + /// + public sealed class ErrorMessageException : Exception + { + /// + /// The . + /// + public ErrorCode? ErrorCode { get; } + + /// + /// The . + /// + public string? AdditionalData { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// Fallback . + public ErrorMessageException(ErrorMessageResponse errorMessage, string fallbackMessage) + : base((errorMessage ?? throw new ArgumentNullException(nameof(errorMessage))).Message ?? fallbackMessage) + { + ErrorCode = errorMessage.ErrorCode != default ? errorMessage.ErrorCode : null; + AdditionalData = errorMessage.AdditionalData; + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + public ErrorMessageException(ErrorCode errorCode) + : this(new ErrorMessageResponse(errorCode), String.Empty) + { + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/ErrorMessageFilter.cs b/src/Tgstation.Server.Host/GraphQL/ErrorMessageFilter.cs new file mode 100644 index 0000000000..cbb9ac05e8 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/ErrorMessageFilter.cs @@ -0,0 +1,79 @@ +using System; + +using HotChocolate; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Response; + +namespace Tgstation.Server.Host.GraphQL +{ + /// + /// for transforming -like . + /// + sealed class ErrorMessageFilter : IErrorFilter + { + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public ErrorMessageFilter(ILogger logger) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public IError OnError(IError error) + { + ArgumentNullException.ThrowIfNull(error); + + if (error.Exception == null) + return error; + + var errorBuilder = ErrorBuilder.FromError(error) + .RemoveException() + .ClearExtensions(); + + const string ErrorCodeFieldName = "errorCode"; + const string AdditionalDataFieldName = "additionalData"; + + if (error.Exception is DbUpdateException dbUpdateException) + { + if (dbUpdateException.InnerException is OperationCanceledException) + { + logger.LogTrace(dbUpdateException, "Rethrowing DbUpdateException as OperationCanceledException"); + throw dbUpdateException.InnerException; + } + + logger.LogDebug(dbUpdateException, "Database conflict!"); + return errorBuilder + .SetMessage(dbUpdateException.Message) + .SetExtension(ErrorCodeFieldName, ErrorCode.DatabaseIntegrityConflict) + .SetExtension(AdditionalDataFieldName, (dbUpdateException.InnerException ?? dbUpdateException).Message) + .Build(); + } + + if (error.Exception is not ErrorMessageException errorMessageException) + { + return errorBuilder + .SetMessage(error.Exception.Message) + .SetExtension(ErrorCodeFieldName, ErrorCode.InternalServerError) + .SetExtension(AdditionalDataFieldName, error.Exception.ToString()) + .Build(); + } + + return errorBuilder + .SetMessage(errorMessageException.Message) + .SetExtension(ErrorCodeFieldName, errorMessageException.ErrorCode) + .SetExtension(AdditionalDataFieldName, errorMessageException.AdditionalData) + .Build(); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Interceptors/RightsTypeInterceptor.cs b/src/Tgstation.Server.Host/GraphQL/Interceptors/RightsTypeInterceptor.cs new file mode 100644 index 0000000000..5fea9058e3 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Interceptors/RightsTypeInterceptor.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +using HotChocolate.Configuration; +using HotChocolate.Types; +using HotChocolate.Types.Descriptors.Definitions; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.GraphQL.Types; + +namespace Tgstation.Server.Host.GraphQL.Interceptors +{ + /// + /// Fixes the names used for the default flags types in API rights. + /// + sealed class RightsTypeInterceptor : TypeInterceptor + { + /// + /// Prefix normally used by hot chocolate for flag enums. + /// + const string IsPrefix = "is"; + + /// + /// Name given to default None fields. + /// + const string NoneFieldName = $"{IsPrefix}None"; + + /// + /// Names of rights GraphQL object types. + /// + private readonly HashSet objectNames; + + /// + /// Names of rights GraphQL input types. + /// + private readonly HashSet inputNames; + + /// + /// Initializes a new instance of the class. + /// + public RightsTypeInterceptor() + { + var rightTypes = Enum.GetValues(); + objectNames = new HashSet(rightTypes.Length); + inputNames = new HashSet(rightTypes.Length); + + foreach (var rightType in rightTypes) + { + var rightName = rightType.ToString(); + var flagName = $"{rightName}RightsFlags"; + + objectNames.Add(flagName); + inputNames.Add($"{flagName}Input"); + } + } + + /// + /// Fix the "is" prefix on a given set of . + /// + /// The of to correct. + /// The of s to operate on. + static void FixFields(IBindableList fields) + where TField : FieldDefinitionBase + { + TField? noneField = null; + + foreach (var field in fields) + { + var fieldName = field.Name; + if (fieldName == NoneFieldName) + { + noneField = field; + continue; + } + + if (!fieldName.StartsWith(IsPrefix)) + throw new InvalidOperationException("Expected flags enum type field to start with \"is\"!"); + + field.Name = $"can{fieldName[IsPrefix.Length..]}"; + } + + if (noneField == null) + throw new InvalidOperationException($"Expected flags enum type field to contain \"{NoneFieldName}\"!"); + + fields.Remove(noneField); + } + + /// + /// Fix the for a tweaked field. + /// + /// The to fix. + static void FixFormatter(IInputValueFormatter inputValueFormatter) + { + // now we're hacking privates, but there's a dictionary with bad keys here that needs adjusting + var dictionary = (Dictionary)(inputValueFormatter + .GetType() + .GetField("_flags", BindingFlags.Instance | BindingFlags.NonPublic) + ?.GetValue(inputValueFormatter) + ?? throw new InvalidOperationException("Could not locate private enum mapping dictionary field!")); + + foreach (var key in dictionary.Keys.ToList()) + { + if (key == NoneFieldName) + { + dictionary.Remove(key); + continue; + } + + var value = dictionary[key]; + var newKey = $"can{key.Substring(IsPrefix.Length)}"; + dictionary.Remove(key); + dictionary.Add(newKey, value); + } + } + + /// + public override void OnAfterRegisterDependencies(ITypeDiscoveryContext discoveryContext, DefinitionBase definition) + { + ArgumentNullException.ThrowIfNull(definition); + + if (definition is ObjectTypeDefinition objectTypeDef) + { + if (objectNames.Contains(objectTypeDef.Name)) + FixFields(objectTypeDef.Fields); + } + else if (definition is InputObjectTypeDefinition inputTypeDef) + { + const string PermissionSetInputName = $"{nameof(PermissionSet)}Input"; + const string InstancePermissionSetInputName = $"{nameof(InstancePermissionSet)}Input"; + + var name = inputTypeDef.Name; + if (inputNames.Contains(name)) + FixFields(inputTypeDef.Fields); + else if (name == PermissionSetInputName || name == InstancePermissionSetInputName) + foreach (var field in inputTypeDef.Fields) + FixFormatter(field.Formatters.Single()); + } + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs b/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs new file mode 100644 index 0000000000..1de582c06f --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs @@ -0,0 +1,24 @@ +using System.Linq; + +using Tgstation.Server.Host.GraphQL.Types; + +namespace Tgstation.Server.Host.GraphQL.Interfaces +{ + /// + /// Management interface for the parent . + /// + public interface IGateway + { + /// + /// Gets . + /// + /// The for the . + GatewayInformation Information(); + + /// + /// Queries all s in the . + /// + /// Queryable s. + IQueryable Instances(); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Interfaces/IServerNode.cs b/src/Tgstation.Server.Host/GraphQL/Interfaces/IServerNode.cs new file mode 100644 index 0000000000..63ca2b3b31 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Interfaces/IServerNode.cs @@ -0,0 +1,21 @@ +using HotChocolate; + +using Microsoft.Extensions.Options; + +using Tgstation.Server.Host.Configuration; + +namespace Tgstation.Server.Host.GraphQL.Interfaces +{ + /// + /// Represents a tgstation-server installation. + /// + public interface IServerNode + { + /// + /// Access the for the . + /// + /// The of . + /// The for the . + public IGateway Gateway([Service] IOptionsSnapshot swarmConfigurationOptions); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Interfaces/IUserName.cs b/src/Tgstation.Server.Host/GraphQL/Interfaces/IUserName.cs new file mode 100644 index 0000000000..f2a57702cc --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Interfaces/IUserName.cs @@ -0,0 +1,21 @@ +using HotChocolate.Types.Relay; + +namespace Tgstation.Server.Host.GraphQL.Interfaces +{ + /// + /// A lightly scoped . + /// + public interface IUserName + { + /// + /// The ID of the user. + /// + [ID] + public long Id { get; } + + /// + /// The name of the user. + /// + public string Name { get; } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Mutation.cs b/src/Tgstation.Server.Host/GraphQL/Mutation.cs index 7988affc6b..c30790791c 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutation.cs @@ -1,11 +1,42 @@ -namespace Tgstation.Server.Host.GraphQL +using System; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Types; + +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.GraphQL.Mutations.Payloads; + +namespace Tgstation.Server.Host.GraphQL { /// /// Root type for GraphQL mutations. /// + /// Intentionally left mostly empty, use type extensions to properly scope operations to domains. + [GraphQLDescription(GraphQLDescription)] public sealed class Mutation { - // Intentionally left blank, use type extensions to properly scope operations to domains - // https://chillicream.com/docs/hotchocolate/v13/defining-a-schema/extending-types + /// + /// Description to show on the type. + /// + public const string GraphQLDescription = "Root Mutation type"; + + /// + /// Generate a JWT for authenticating with server. This is the only operation that accepts and required basic authentication. + /// + /// The for the . + /// The for the operation. + /// A Bearer token to be used with further communication with the server. + [Error(typeof(ErrorMessageException))] + public ValueTask Login( + [Service] IGraphQLAuthorityInvoker loginAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(loginAuthority); + + return loginAuthority.Invoke( + authority => authority.AttemptLogin(cancellationToken)); + } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginResult.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginResult.cs new file mode 100644 index 0000000000..cf30206115 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginResult.cs @@ -0,0 +1,33 @@ +using HotChocolate; +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.GraphQL.Scalars; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads +{ + /// + /// Success response for a login attempt. + /// + public sealed class LoginResult : ILegacyApiTransformable + { + /// + /// The JSON Web Token (JWT) to use as a Bearer token for accessing the server. Contains an expiry time. + /// + [GraphQLType] + [GraphQLNonNullType] + public required string Bearer { get; init; } + + /// + /// The that was logged in. + /// + public required Types.User User { get; init; } + + /// + [GraphQLIgnore] + public TokenResponse ToApi() + => new() + { + Bearer = Bearer, + }; + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/PermissionSetInput.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/PermissionSetInput.cs new file mode 100644 index 0000000000..9e0ffc27a1 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/PermissionSetInput.cs @@ -0,0 +1,20 @@ +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads +{ + /// + /// Updates a set of permissions for the server. values default to their "None" variants. + /// + public sealed class PermissionSetInput + { + /// + /// The for the . + /// + public required AdministrationRights? AdministrationRights { get; init; } + + /// + /// The for the . + /// + public required InstanceManagerRights? InstanceManagerRights { get; init; } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs new file mode 100644 index 0000000000..7f27d8ea76 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs @@ -0,0 +1,105 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Types; +using HotChocolate.Types.Relay; + +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.GraphQL.Mutations.Payloads; +using Tgstation.Server.Host.GraphQL.Types; +using Tgstation.Server.Host.Models.Transformers; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.GraphQL.Mutations +{ + /// + /// related s. + /// + [ExtendObjectType(typeof(Mutation))] + [GraphQLDescription(Mutation.GraphQLDescription)] + public sealed class UserGroupMutations + { + /// + /// Transform a into a . + /// + /// The to transform. + /// The transformed . + static Models.PermissionSet? TransformApiPermissionSet(PermissionSetInput? permissionSet) + => permissionSet != null + ? new Models.PermissionSet + { + InstanceManagerRights = permissionSet?.InstanceManagerRights, + AdministrationRights = permissionSet?.AdministrationRights, + } + : null; + + /// + /// Creates a . + /// + /// The of the . + /// The initial permission set for the . + /// The for the . + /// The for the operation. + /// The created . + [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.Create))] + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserGroup( + string name, + PermissionSetInput? permissionSet, + [Service] IGraphQLAuthorityInvoker userGroupAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(userGroupAuthority); + + return userGroupAuthority.InvokeTransformable( + authority => authority.Create(name, TransformApiPermissionSet(permissionSet), cancellationToken)); + } + + /// + /// Updates a . + /// + /// The of the to update. + /// Optional new for the . + /// Optional new permission set for the . + /// The for the . + /// The for the operation. + /// The updated . + [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.Update))] + [Error(typeof(ErrorMessageException))] + public ValueTask UpdateUserGroup( + [ID(nameof(UserGroup))] long id, + string? newName, + PermissionSetInput? newPermissionSet, + [Service] IGraphQLAuthorityInvoker userGroupAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userGroupAuthority); + return userGroupAuthority.InvokeTransformable( + authority => authority.Update(id, newName, TransformApiPermissionSet(newPermissionSet), cancellationToken)); + } + + /// + /// Deletes a . + /// + /// The of the to update. + /// The for the . + /// The for the operation. + /// The root. + [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.DeleteEmpty))] + [Error(typeof(ErrorMessageException))] + public async ValueTask DeleteEmptyUserGroup( + [ID(nameof(UserGroup))] long id, + [Service] IGraphQLAuthorityInvoker userGroupAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userGroupAuthority); + await userGroupAuthority.Invoke( + authority => authority.DeleteEmpty(id, cancellationToken)); + + return new Query(); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs new file mode 100644 index 0000000000..77fc7e3445 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs @@ -0,0 +1,545 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Types; +using HotChocolate.Types.Relay; + +using Tgstation.Server.Api.Models.Request; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.GraphQL.Mutations.Payloads; +using Tgstation.Server.Host.GraphQL.Types; +using Tgstation.Server.Host.GraphQL.Types.OAuth; +using Tgstation.Server.Host.Models.Transformers; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.GraphQL.Mutations +{ + /// + /// related s. + /// + [ExtendObjectType(typeof(Mutation))] + [GraphQLDescription(Mutation.GraphQLDescription)] + public sealed class UserMutations + { + /// + /// Creates a TGS user specifying a personal . + /// + /// The of the . + /// The password of the . + /// If the is . + /// The s for the user. + /// The owned of the user. + /// The for the . + /// The for the operation. + /// The created . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserByPasswordAndPermissionSet( + string name, + string password, + bool? enabled, + IEnumerable? oAuthConnections, + PermissionSetInput? permissionSet, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(password); + ArgumentNullException.ThrowIfNull(userAuthority); + + return userAuthority.InvokeTransformable( + authority => authority.Create( + new UserCreateRequest + { + Name = name, + Password = password, + Enabled = enabled, + PermissionSet = permissionSet != null + ? new Api.Models.PermissionSet + { + AdministrationRights = permissionSet.AdministrationRights, + InstanceManagerRights = permissionSet.InstanceManagerRights, + } + : null, + OAuthConnections = oAuthConnections + ?.Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + false, + cancellationToken)); + } + + /// + /// Creates a TGS user specifying the they will belong to. + /// + /// The of the . + /// The password of the . + /// If the is . + /// The s for the user. + /// The of the the will belong to. + /// The for the . + /// The for the operation. + /// The created . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserByPasswordAndGroup( + string name, + string password, + bool? enabled, + IEnumerable? oAuthConnections, + [ID(nameof(UserGroup))] long groupId, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(password); + ArgumentNullException.ThrowIfNull(userAuthority); + + return userAuthority.InvokeTransformable( + authority => authority.Create( + new UserCreateRequest + { + Name = name, + Password = password, + Enabled = enabled, + Group = new Api.Models.Internal.UserGroup + { + Id = groupId, + }, + OAuthConnections = oAuthConnections + ?.Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + false, + cancellationToken)); + } + + /// + /// Creates a TGS user authenticated with one or mor s specifying a personal . + /// + /// The of the . + /// The s for the user. + /// If the is . + /// The owned of the user. + /// The for the . + /// The for the operation. + /// The created . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserByOAuthAndPermissionSet( + string name, + IEnumerable oAuthConnections, + bool? enabled, + PermissionSetInput? permissionSet, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(oAuthConnections); + ArgumentNullException.ThrowIfNull(userAuthority); + + return userAuthority.InvokeTransformable( + authority => authority.Create( + new UserCreateRequest + { + Name = name, + Password = String.Empty, + Enabled = enabled, + PermissionSet = permissionSet != null + ? new Api.Models.PermissionSet + { + AdministrationRights = permissionSet.AdministrationRights, + InstanceManagerRights = permissionSet.InstanceManagerRights, + } + : null, + OAuthConnections = oAuthConnections + .Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + true, + cancellationToken)); + } + + /// + /// Creates a TGS user specifying the they will belong to. + /// + /// The of the . + /// The s for the user. + /// The of the the will belong to. + /// If the is . + /// The for the . + /// The for the operation. + /// The created . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserByOAuthAndGroup( + string name, + IEnumerable oAuthConnections, + [ID(nameof(UserGroup))] long groupId, + bool? enabled, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(oAuthConnections); + ArgumentNullException.ThrowIfNull(userAuthority); + + return userAuthority.InvokeTransformable( + authority => authority.Create( + new UserCreateRequest + { + Name = name, + Password = String.Empty, + Enabled = enabled, + Group = new Api.Models.Internal.UserGroup + { + Id = groupId, + }, + OAuthConnections = oAuthConnections + .Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + true, + cancellationToken)); + } + + /// + /// Creates a system user specifying a personal . + /// + /// The of the . + /// If the is . + /// The s for the user. + /// The owned of the user. + /// The for the . + /// The for the operation. + /// The created . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserBySystemIDAndPermissionSet( + string systemIdentifier, + bool? enabled, + IEnumerable? oAuthConnections, + PermissionSetInput permissionSet, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(systemIdentifier); + ArgumentNullException.ThrowIfNull(userAuthority); + + return userAuthority.InvokeTransformable( + authority => authority.Create( + new UserCreateRequest + { + SystemIdentifier = systemIdentifier, + Enabled = enabled, + PermissionSet = permissionSet != null + ? new Api.Models.PermissionSet + { + AdministrationRights = permissionSet.AdministrationRights, + InstanceManagerRights = permissionSet.InstanceManagerRights, + } + : null, + OAuthConnections = oAuthConnections + ?.Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + false, + cancellationToken)); + } + + /// + /// Creates a system user specifying the they will belong to. + /// + /// The of the . + /// If the is . + /// The of the the will belong to. + /// The s for the user. + /// The for the . + /// The for the operation. + /// The created . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserBySystemIDAndGroup( + string systemIdentifier, + bool? enabled, + [ID(nameof(UserGroup))] long groupId, + IEnumerable? oAuthConnections, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(systemIdentifier); + ArgumentNullException.ThrowIfNull(userAuthority); + + return userAuthority.InvokeTransformable( + authority => authority.Create( + new UserCreateRequest + { + SystemIdentifier = systemIdentifier, + Enabled = enabled, + Group = new Api.Models.Internal.UserGroup + { + Id = groupId, + }, + OAuthConnections = oAuthConnections + ?.Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + false, + cancellationToken)); + } + + /// + /// Sets the current user's password. + /// + /// The new password for the current user. + /// The to get the of the user. + /// The for the . + /// The for the operation. + /// The updated current . + [TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)] + [Error(typeof(ErrorMessageException))] + public ValueTask SetCurrentUserPassword( + string newPassword, + [Service] IAuthenticationContext authenticationContext, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(newPassword); + ArgumentNullException.ThrowIfNull(userAuthority); + return userAuthority.InvokeTransformable( + async authority => await authority.Update( + new UserUpdateRequest + { + Id = authenticationContext.User.Id, + Password = newPassword, + }, + cancellationToken)); + } + + /// + /// Sets the current user's s. + /// + /// The new s for the current user. + /// The to get the of the user. + /// The for the . + /// The for the operation. + /// The updated current . + [TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnOAuthConnections)] + [Error(typeof(ErrorMessageException))] + public ValueTask SetCurrentOAuthConnections( + IEnumerable newOAuthConnections, + [Service] IAuthenticationContext authenticationContext, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(newOAuthConnections); + ArgumentNullException.ThrowIfNull(userAuthority); + return userAuthority.InvokeTransformable( + async authority => await authority.Update( + new UserUpdateRequest + { + Id = authenticationContext.User.Id, + OAuthConnections = newOAuthConnections + .Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + cancellationToken)); + } + + /// + /// Updates a s properties. + /// + /// The of the to update. + /// Optional casing only change to the of the . Only applicable to TGS users. + /// Optional new password for the . Only applicable to TGS users. + /// Optional new status for the . + /// Optional new s for the . + /// The for the . + /// The for the operation. + /// The updated . + [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] + [Error(typeof(ErrorMessageException))] + public ValueTask UpdateUser( + [ID(nameof(User))] long id, + string? casingOnlyNameChange, + string? newPassword, + bool? enabled, + IEnumerable? newOAuthConnections, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + return UpdateUserCore( + id, + casingOnlyNameChange, + newPassword, + enabled, + null, + null, + newOAuthConnections, + userAuthority, + cancellationToken); + } + + /// + /// Updates a , setting new values for its owned . + /// + /// The of the to update. + /// Optional casing only change to the of the . Only applicable to TGS users. + /// Optional new password for the . Only applicable to TGS users. + /// Optional new status for the . + /// Updated owned for the user. Note that setting this on a in a will remove them from that group. + /// The new s for the . + /// The for the . + /// The for the operation. + /// The updated . + [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] + [Error(typeof(ErrorMessageException))] + public ValueTask UpdateUserSetOwnedPermissionSet( + [ID(nameof(User))] long id, + string? casingOnlyNameChange, + string? newPassword, + bool? enabled, + PermissionSetInput newPermissionSet, + IEnumerable? newOAuthConnections, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + return UpdateUserCore( + id, + casingOnlyNameChange, + newPassword, + enabled, + newPermissionSet, + null, + newOAuthConnections, + userAuthority, + cancellationToken); + } + + /// + /// Updates a , setting new values for its owned . + /// + /// The of the to update. + /// Optional casing only change to the of the . Only applicable to TGS users. + /// Optional new password for the . Only applicable to TGS users. + /// Optional new status for the . + /// of the to move the to. + /// The new s for the . + /// The for the . + /// The for the operation. + /// The updated . + [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] + [Error(typeof(ErrorMessageException))] + public ValueTask UpdateUserSetGroup( + [ID(nameof(User))] long id, + string? casingOnlyNameChange, + string? newPassword, + bool? enabled, + [ID(nameof(UserGroup))] long newGroupId, + IEnumerable? newOAuthConnections, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + return UpdateUserCore( + id, + casingOnlyNameChange, + newPassword, + enabled, + null, + newGroupId, + newOAuthConnections, + userAuthority, + cancellationToken); + } + + /// + /// Updates a user. + /// + /// The of the to update. + /// Optional casing only change to the of the . Only applicable to TGS users. + /// Optional new password for the . Only applicable to TGS users. + /// Optional new status for the . + /// Optional updated new owned for the user. Note that setting this on a in a will remove them from that group. + /// Optional of the to move the to. + /// Optional new s for the . + /// The for the . + /// The for the operation. + /// The updated . + ValueTask UpdateUserCore( + [ID(nameof(User))] long id, + string? casingOnlyNameChange, + string? newPassword, + bool? enabled, + PermissionSetInput? newPermissionSet, + long? newGroupId, + IEnumerable? newOAuthConnections, + IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + => userAuthority.InvokeTransformable( + async authority => await authority.Update( + new UserUpdateRequest + { + Id = id, + Name = casingOnlyNameChange, + Password = newPassword, + Enabled = enabled, + PermissionSet = newPermissionSet != null + ? new Api.Models.PermissionSet + { + InstanceManagerRights = newPermissionSet.InstanceManagerRights, + AdministrationRights = newPermissionSet.AdministrationRights, + } + : null, + Group = newGroupId.HasValue + ? new Api.Models.Internal.UserGroup + { + Id = newGroupId.Value, + } + : null, + OAuthConnections = newOAuthConnections + ?.Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + cancellationToken)); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Query.cs b/src/Tgstation.Server.Host/GraphQL/Query.cs index 3b57958392..660040d41d 100644 --- a/src/Tgstation.Server.Host/GraphQL/Query.cs +++ b/src/Tgstation.Server.Host/GraphQL/Query.cs @@ -1,18 +1,19 @@ -#pragma warning disable CA1724 +#pragma warning disable CA1724 // Dumb conflict with Microsoft.EntityFrameworkCore.Query -using Tgstation.Server.Host.GraphQL.Types; +using HotChocolate; namespace Tgstation.Server.Host.GraphQL { /// /// GraphQL query . /// + [GraphQLDescription("Root Query type")] public sealed class Query { /// - /// Gets the . + /// Gets the . /// - /// A new . - public ServerSwarm Swarm() => new(); + /// A new . + public Types.ServerSwarm Swarm() => new(); } } diff --git a/src/Tgstation.Server.Host/GraphQL/Scalars/JwtType.cs b/src/Tgstation.Server.Host/GraphQL/Scalars/JwtType.cs new file mode 100644 index 0000000000..170a744a42 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Scalars/JwtType.cs @@ -0,0 +1,38 @@ +using System; + +using HotChocolate.Language; +using HotChocolate.Types; + +namespace Tgstation.Server.Host.GraphQL.Scalars +{ + /// + /// A for encoded JSON Web Tokens. + /// + public sealed class JwtType : ScalarType + { + /// + /// Initializes a new instance of the class. + /// + public JwtType() + : base("Jwt") + { + Description = "Represents an encoded JSON Web Token"; + SpecifiedBy = new Uri("https://datatracker.ietf.org/doc/html/rfc7519"); + } + + /// + public override IValueNode ParseResult(object? resultValue) + => ParseValue(resultValue); + + /// + protected override string ParseLiteral(StringValueNode valueSyntax) + { + ArgumentNullException.ThrowIfNull(valueSyntax); + return valueSyntax.Value; + } + + /// + protected override StringValueNode ParseValue(string runtimeValue) + => new(runtimeValue); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs b/src/Tgstation.Server.Host/GraphQL/Scalars/SemverType.cs similarity index 89% rename from src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs rename to src/Tgstation.Server.Host/GraphQL/Scalars/SemverType.cs index 76fbb55eed..de8c1e530a 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs +++ b/src/Tgstation.Server.Host/GraphQL/Scalars/SemverType.cs @@ -4,7 +4,7 @@ using HotChocolate.Language; using HotChocolate.Types; using Tgstation.Server.Common.Extensions; -namespace Tgstation.Server.Host.GraphQL.Types.Scalars +namespace Tgstation.Server.Host.GraphQL.Scalars { /// /// A for semantic s. @@ -17,7 +17,8 @@ namespace Tgstation.Server.Host.GraphQL.Types.Scalars public SemverType() : base("Semver") { - Description = "Represents a version in semver format as defined by https://semver.org/spec/v2.0.0.html"; + Description = "Represents a version in semantic versioning format"; + SpecifiedBy = new Uri("https://semver.org/spec/v2.0.0.html"); } /// @@ -60,7 +61,7 @@ namespace Tgstation.Server.Host.GraphQL.Types.Scalars /// protected override StringValueNode ParseValue(Version runtimeValue) - => new StringValueNode(runtimeValue.Semver().ToString()); + => new(runtimeValue.Semver().ToString()); /// protected override bool IsInstanceOfType(StringValueNode valueSyntax) diff --git a/src/Tgstation.Server.Host/GraphQL/Subscription.cs b/src/Tgstation.Server.Host/GraphQL/Subscription.cs new file mode 100644 index 0000000000..170dceb49a --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Subscription.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Execution; +using HotChocolate.Subscriptions; +using HotChocolate.Types; + +using Tgstation.Server.Host.GraphQL.Subscriptions; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.GraphQL +{ + /// + /// Root type for GraphQL subscriptions. + /// + /// Intentionally left mostly empty, use type extensions to properly scope operations to domains. + [GraphQLDescription(GraphQLDescription)] + public sealed class Subscription + { + /// + /// Description to show on the type. + /// + public const string GraphQLDescription = "Root Subscription type. Note that subscriptions are performed over Server Sent events."; + + /// + /// Gets the topic name for the login session represented by a given . + /// + /// The to generate the topic for. + /// The topic for the given . + public static string SessionInvalidatedTopic(IAuthenticationContext authenticationContext) + { + ArgumentNullException.ThrowIfNull(authenticationContext); + return $"SessionInvalidated.{authenticationContext.SessionId}"; + } + + /// + /// for . + /// + /// The . + /// The . + /// The for the request. + /// The for the operation. + /// A resulting in a of the for the . + public ValueTask> SessionInvalidatedStream( + [Service] HotChocolate.Subscriptions.ITopicEventReceiver receiver, // Intentionally not using our override here, topic callers are built to explicitly handle cases of server shutdown + [Service] ISessionInvalidationTracker invalidationTracker, + [Service] IAuthenticationContext authenticationContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(receiver); + ArgumentNullException.ThrowIfNull(invalidationTracker); + + var subscription = receiver.SubscribeAsync(SessionInvalidatedTopic(authenticationContext), cancellationToken); + invalidationTracker.TrackSession(authenticationContext); + return subscription; + } + + /// + /// Receive a immediately before the current login session is invalidated. + /// + /// The received from the publisher. + /// The . + [Subscribe(With = nameof(SessionInvalidatedStream))] + [TgsGraphQLAuthorize] + public SessionInvalidationReason SessionInvalidated([EventMessage] SessionInvalidationReason sessionInvalidationReason) + => sessionInvalidationReason; + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Subscriptions/ITopicEventReceiver.cs b/src/Tgstation.Server.Host/GraphQL/Subscriptions/ITopicEventReceiver.cs new file mode 100644 index 0000000000..ec843558cb --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Subscriptions/ITopicEventReceiver.cs @@ -0,0 +1,9 @@ +namespace Tgstation.Server.Host.GraphQL.Subscriptions +{ + /// + /// Implementation of that works around the issue described in https://github.com/ChilliCream/graphql-platform/issues/6698. + /// + public interface ITopicEventReceiver : HotChocolate.Subscriptions.ITopicEventReceiver + { + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Subscriptions/SessionInvalidationReason.cs b/src/Tgstation.Server.Host/GraphQL/Subscriptions/SessionInvalidationReason.cs new file mode 100644 index 0000000000..37e9867813 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Subscriptions/SessionInvalidationReason.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Host.GraphQL.Subscriptions +{ + /// + /// Reasons TGS may invalidate a user's login session. + /// + public enum SessionInvalidationReason + { + /// + /// The callers JWT expired. + /// + TokenExpired, + + /// + /// An update to the caller's identity requiring reauthentication was made. + /// + UserUpdated, + + /// + /// TGS is shutting down or restarting. Note, depending on server configuration, the current session may not actually be invalid upon restarting. However, the information required to determine this is not exposed to clients. + /// + ServerShutdown, + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Subscriptions/ShutdownAwareTopicEventReceiver.cs b/src/Tgstation.Server.Host/GraphQL/Subscriptions/ShutdownAwareTopicEventReceiver.cs new file mode 100644 index 0000000000..1fec5ee14e --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Subscriptions/ShutdownAwareTopicEventReceiver.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate.Execution; +using HotChocolate.Subscriptions; + +using Microsoft.Extensions.Hosting; + +namespace Tgstation.Server.Host.GraphQL.Subscriptions +{ + /// + sealed class ShutdownAwareTopicEventReceiver : ITopicEventReceiver, IAsyncDisposable + { + /// + /// The for the . + /// + readonly IHostApplicationLifetime hostApplicationLifetime; + + /// + /// The wrapped . + /// + readonly HotChocolate.Subscriptions.ITopicEventReceiver hotChocolateReceiver; + + /// + /// A of s that were created for this scope. + /// + readonly ConcurrentBag registrations; + + /// + /// A of s returned from initiating calls on s. + /// + readonly ConcurrentBag disposeTasks; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public ShutdownAwareTopicEventReceiver( + IHostApplicationLifetime hostApplicationLifetime, + HotChocolate.Subscriptions.ITopicEventReceiver hotChocolateReceiver) + { + this.hostApplicationLifetime = hostApplicationLifetime ?? throw new ArgumentNullException(nameof(hostApplicationLifetime)); + this.hotChocolateReceiver = hotChocolateReceiver ?? throw new ArgumentNullException(nameof(hotChocolateReceiver)); + + registrations = new ConcurrentBag(); + disposeTasks = new ConcurrentBag(); + } + + /// + public async ValueTask DisposeAsync() + { + foreach (var registration in registrations) + { + registration.Dispose(); + } + + await Task.WhenAll(disposeTasks); + } + + /// + public ValueTask> SubscribeAsync(string topicName, CancellationToken cancellationToken) + => WrapWithApplicationLifetimeCancellation( + hotChocolateReceiver.SubscribeAsync(topicName, cancellationToken)); + + /// + public ValueTask> SubscribeAsync(string topicName, int? bufferCapacity, TopicBufferFullMode? bufferFullMode, CancellationToken cancellationToken) + => WrapWithApplicationLifetimeCancellation( + hotChocolateReceiver.SubscribeAsync(topicName, bufferCapacity, bufferFullMode, cancellationToken)); + + /// + /// Wraps a given with cancellation awareness. + /// + /// The of message. + /// The result of a call to the . + /// The result of with lifetime aware cancellation. + async ValueTask> WrapWithApplicationLifetimeCancellation(ValueTask> sourceStreamTask) + { + var sourceStream = await sourceStreamTask; + registrations.Add( + hostApplicationLifetime.ApplicationStopping.Register( + () => disposeTasks.Add( + sourceStream.DisposeAsync().AsTask()))); + return sourceStream; + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs b/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs new file mode 100644 index 0000000000..35be564c52 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Execution; +using HotChocolate.Types; +using HotChocolate.Types.Relay; + +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.GraphQL.Types; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.GraphQL.Subscriptions +{ + /// + /// Subscriptions for . + /// + [ExtendObjectType(typeof(Subscription))] + [GraphQLDescription(Subscription.GraphQLDescription)] + public sealed class UserSubscriptions + { + /// + /// The name of the topic for when any user is updated. + /// + const string UserUpdatedTopic = "UserUpdated"; + + /// + /// Get the names of the topics to send to when a is updated. + /// + /// The of the updated . + /// An of topic s. + public static IEnumerable UserUpdatedTopics(long userId) + { + yield return UserUpdatedTopic; + yield return SpecificUserUpdatedTopic(userId); + } + + /// + /// The name of the topic for when a specific is updated. + /// + /// The of the updated . + /// The topic . + static string SpecificUserUpdatedTopic(long userId) + => $"{UserUpdatedTopic}.{userId}"; + + /// + /// for . + /// + /// The optional of the to scope updates to. + /// The . + /// The for the operation. + /// A resulting in the requested updates. + public ValueTask> UserUpdatedStream( + [ID(nameof(User))] long? userId, + [Service] ITopicEventReceiver receiver, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(receiver); + var topic = userId.HasValue ? SpecificUserUpdatedTopic(userId.Value) : UserUpdatedTopic; + return receiver.SubscribeAsync(topic, cancellationToken); + } + + /// + /// Receive an update for all changes. + /// + /// The received from the publisher. + /// The updated . + [Subscribe(With = nameof(UserUpdatedStream))] + [TgsGraphQLAuthorize(AdministrationRights.ReadUsers)] + public User UserUpdated([EventMessage] User user) + { + ArgumentNullException.ThrowIfNull(user); + return user; + } + + /// + /// for . + /// + /// The . + /// The for the request. + /// The for the operation. + /// A resulting in updates for the current . + public ValueTask> CurrentUserUpdatedStream( + [Service] ITopicEventReceiver receiver, + [Service] IAuthenticationContext authenticationContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(receiver); + ArgumentNullException.ThrowIfNull(authenticationContext); + return receiver.SubscribeAsync(SpecificUserUpdatedTopic(Models.ModelExtensions.Require(authenticationContext.User, user => user.Id)), cancellationToken); + } + + /// + /// Receive an update to the logged in when it is changed. + /// + /// The received from the publisher. + /// The updated . + [Subscribe(With = nameof(CurrentUserUpdatedStream))] + [TgsGraphQLAuthorize] + public User CurrentUserUpdated([EventMessage] User user) + { + ArgumentNullException.ThrowIfNull(user); + + return user; + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs b/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs index 20c8a08b80..4f45c22306 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs @@ -1,4 +1,8 @@ -namespace Tgstation.Server.Host.GraphQL.Types +using System.Diagnostics.CodeAnalysis; + +using HotChocolate.Types.Relay; + +namespace Tgstation.Server.Host.GraphQL.Types { /// /// Represents a database entity. @@ -8,12 +12,21 @@ /// /// The ID of the . /// - public long Id { get; } + [ID] + public required long Id { get; init; } + + /// + /// Initializes a new instance of the class. + /// + protected Entity() + { + } /// /// Initializes a new instance of the class. /// /// The value of . + [SetsRequiredMembers] protected Entity(long id) { Id = id; diff --git a/src/Tgstation.Server.Host/GraphQL/Types/GatewayInformation.cs b/src/Tgstation.Server.Host/GraphQL/Types/GatewayInformation.cs new file mode 100644 index 0000000000..9a1aee31cf --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/GatewayInformation.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; + +using HotChocolate; + +using Microsoft.Extensions.Options; + +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Components.Interop; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.GraphQL.Types.OAuth; +using Tgstation.Server.Host.Properties; +using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Security.OAuth; +using Tgstation.Server.Host.System; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represents information about a retrieved via a . + /// + public sealed class GatewayInformation + { + /// + /// Gets the minimum valid password length for TGS users. + /// + /// The containing the . + /// A specifying the minimumn valid password length for TGS users. + [TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)] + public uint MinimumPasswordLength( + [Service] IOptionsSnapshot generalConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + return generalConfigurationOptions.Value.MinimumPasswordLength; + } + + /// + /// Gets the maximum allowed attached instances for the . + /// + /// The containing the . + /// A specifying the maximum allowed attached instances for the . + [TgsGraphQLAuthorize(InstanceManagerRights.Create)] + public uint InstanceLimit( + [Service] IOptionsSnapshot generalConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + return generalConfigurationOptions.Value.InstanceLimit; + } + + /// + /// Gets the maximum allowed registered s for the . + /// + /// The containing the . + /// A specifying the maximum allowed registered users for the . + /// This limit only applies to user creation attempts made via the current . + [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] + public uint UserLimit( + [Service] IOptionsSnapshot generalConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + return generalConfigurationOptions.Value.UserLimit; + } + + /// + /// Gets the maximum allowed registered s for the . + /// + /// The containing the . + /// A specifying the maximum allowed registered s for the . + /// This limit only applies to creation attempts made via the current . + [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] + public uint UserGroupLimit( + [Service] IOptionsSnapshot generalConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + return generalConfigurationOptions.Value.UserGroupLimit; + } + + /// + /// Gets the locations s may be created or attached from if there are restrictions. + /// + /// The containing the . + /// The locations s may be created or attached from if there are restrictions, otherwise. + [TgsGraphQLAuthorize(InstanceManagerRights.Create | InstanceManagerRights.Relocate)] + public IReadOnlyCollection? ValidInstancePaths( + [Service] IOptionsSnapshot generalConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + return generalConfigurationOptions.Value.ValidInstancePaths; + } + + /// + /// Gets a flag indicating whether or not the current runs on a Windows operating system. + /// + /// The to use. + /// if the runs on a Windows operating system, otherwise. + [TgsGraphQLAuthorize] + public bool WindowsHost( + [Service] IPlatformIdentifier platformIdentifier) + { + ArgumentNullException.ThrowIfNull(platformIdentifier); + return platformIdentifier.IsWindows; + } + + /// + /// Gets the swarm protocol . + /// + [TgsGraphQLAuthorize] + public Version SwarmProtocolVersion => global::System.Version.Parse(MasterVersionsAttribute.Instance.RawSwarmProtocolVersion); + + /// + /// Gets the of tgstation-server the is running. + /// + /// The to use. + /// The of tgstation-server the is running. + [TgsGraphQLAuthorize] + public Version Version( + [Service] IAssemblyInformationProvider assemblyInformationProvider) + { + ArgumentNullException.ThrowIfNull(assemblyInformationProvider); + return assemblyInformationProvider.Version; + } + + /// + /// Gets the major GraphQL API number of the . + /// + public int MajorGraphQLApiVersion => GraphQLApiVersion.Major; + + /// + /// Gets the GraphQL API of the . + /// + [TgsGraphQLAuthorize] + public Version GraphQLApiVersion => global::System.Version.Parse(MasterVersionsAttribute.Instance.RawGraphQLVersion); + + /// + /// Gets the REST API of the . + /// + [TgsGraphQLAuthorize] + public Version ApiVersion => ApiHeaders.Version; + + /// + /// Gets the DMAPI interop the uses. + /// + [TgsGraphQLAuthorize] + public Version DMApiVersion => DMApiConstants.InteropVersion; + + /// + /// Gets the information needed to perform open authentication with the . + /// + /// The to use. + /// A map of enabled s to their . + public OAuthProviderInfos OAuthProviderInfos( + [Service] IOAuthProviders oAuthProviders) + { + ArgumentNullException.ThrowIfNull(oAuthProviders); + return new OAuthProviderInfos(oAuthProviders); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs b/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs new file mode 100644 index 0000000000..192a5b377b --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs @@ -0,0 +1,18 @@ +using System; +using System.Linq; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represents a game server instance. + /// + public sealed class Instance : Entity + { + /// + /// Queries all s in the . + /// + /// Queryable s. + public IQueryable QueryableInstancePermissionSets() + => throw new NotImplementedException(); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs b/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs new file mode 100644 index 0000000000..76bcabcac6 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs @@ -0,0 +1,45 @@ +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represents a set of permissions for an . + /// + public sealed class InstancePermissionSet + { + /// + /// The of the . + /// + public InstancePermissionSetRights? InstancePermissionSetRights { get; set; } + + /// + /// The of the . + /// + public EngineRights? EngineRights { get; set; } + + /// + /// The of the . + /// + public DreamDaemonRights? DreamDaemonRights { get; set; } + + /// + /// The of the . + /// + public DreamMakerRights? DreamMakerRights { get; set; } + + /// + /// The of the . + /// + public RepositoryRights? RepositoryRights { get; set; } + + /// + /// The of the . + /// + public ChatBotRights? ChatBotRights { get; set; } + + /// + /// The of the . + /// + public ConfigurationRights? ConfigurationRights { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs b/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs new file mode 100644 index 0000000000..8b6c329d94 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs @@ -0,0 +1,20 @@ +using System; +using System.Linq; + +using Tgstation.Server.Host.GraphQL.Interfaces; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// for the this query is executing on. + /// + public sealed class LocalGateway : IGateway + { + /// + public GatewayInformation Information() => new(); + + /// + public IQueryable Instances() + => throw new NotImplementedException(); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/LocalServer.cs b/src/Tgstation.Server.Host/GraphQL/Types/LocalServer.cs deleted file mode 100644 index fab7784385..0000000000 --- a/src/Tgstation.Server.Host/GraphQL/Types/LocalServer.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; - -using HotChocolate; -using HotChocolate.Authorization; - -using Microsoft.Extensions.Options; -using Tgstation.Server.Api.Models.Internal; -using Tgstation.Server.Host.Configuration; -using Tgstation.Server.Host.Security.OAuth; -using Tgstation.Server.Host.System; - -namespace Tgstation.Server.Host.GraphQL.Types -{ - /// - /// Represents the local tgstation-server. - /// - public sealed class LocalServer - { - /// - /// Gets . - /// - /// The to use. - /// The to use. - /// The containing the to use. - /// A new . - [AllowAnonymous] - public LocalServerInformation Information( - [Service] IOAuthProviders oAuthProviders, - [Service] IPlatformIdentifier platformIdentifier, - [Service] IOptionsSnapshot generalConfigurationOptions) - { - ArgumentNullException.ThrowIfNull(oAuthProviders); - ArgumentNullException.ThrowIfNull(platformIdentifier); - ArgumentNullException.ThrowIfNull(generalConfigurationOptions); - - var generalConfiguration = generalConfigurationOptions.Value; - return new LocalServerInformation - { - MinimumPasswordLength = generalConfiguration.MinimumPasswordLength, - InstanceLimit = generalConfiguration.InstanceLimit, - UserLimit = generalConfiguration.UserLimit, - UserGroupLimit = generalConfiguration.UserGroupLimit, - ValidInstancePaths = generalConfiguration.ValidInstancePaths, - WindowsHost = platformIdentifier.IsWindows, - OAuthProviderInfos = oAuthProviders.ProviderInfos(), - }; - } - } -} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs b/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs index e48c18ce14..d0a36221cc 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs @@ -1,4 +1,7 @@ using System; +using System.Diagnostics.CodeAnalysis; + +using Tgstation.Server.Host.GraphQL.Interfaces; namespace Tgstation.Server.Host.GraphQL.Types { @@ -10,13 +13,32 @@ namespace Tgstation.Server.Host.GraphQL.Types /// /// The name of the . /// - public string Name { get; } + public required string Name { get; init; } + + /// + /// Initializes a new instance of the class. + /// + protected NamedEntity() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The to copy. + [SetsRequiredMembers] + protected NamedEntity(NamedEntity copy) + : base(copy?.Id ?? throw new ArgumentNullException(nameof(copy))) + { + Name = copy.Name; + } /// /// Initializes a new instance of the class. /// /// The ID for the . /// The value of . + [SetsRequiredMembers] protected NamedEntity(long id, string name) : base(id) { diff --git a/src/Tgstation.Server.Host/GraphQL/Types/OAuth/BasicOAuthProviderInfo.cs b/src/Tgstation.Server.Host/GraphQL/Types/OAuth/BasicOAuthProviderInfo.cs new file mode 100644 index 0000000000..f503d7adc8 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/OAuth/BasicOAuthProviderInfo.cs @@ -0,0 +1,28 @@ +using System; + +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.GraphQL.Types.OAuth +{ + /// + /// Basic OAuth provider info. + /// + public class BasicOAuthProviderInfo + { + /// + /// The client ID. + /// + public string ClientID { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The to build from. + public BasicOAuthProviderInfo(OAuthProviderInfo providerInfo) + { + ArgumentNullException.ThrowIfNull(providerInfo); + + ClientID = providerInfo.ClientId ?? throw new InvalidOperationException("ClientID not set!"); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/OAuth/FullOAuthProviderInfo.cs b/src/Tgstation.Server.Host/GraphQL/Types/OAuth/FullOAuthProviderInfo.cs new file mode 100644 index 0000000000..cd750f665b --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/OAuth/FullOAuthProviderInfo.cs @@ -0,0 +1,29 @@ +using System; + +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.GraphQL.Types.OAuth +{ + /// + /// OAuth provider info with a and . + /// + public sealed class FullOAuthProviderInfo : RedirectOAuthProviderInfo + { + /// + /// The remote service URL. + /// + public Uri ServerUrl { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The to build from. + public FullOAuthProviderInfo(OAuthProviderInfo providerInfo) + : base(providerInfo) + { + ArgumentNullException.ThrowIfNull(providerInfo); + + ServerUrl = providerInfo.ServerUrl ?? throw new InvalidOperationException("Missing OAuthProviderInfo ServerUrl!"); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/OAuthConnection.cs b/src/Tgstation.Server.Host/GraphQL/Types/OAuth/OAuthConnection.cs similarity index 94% rename from src/Tgstation.Server.Host/GraphQL/Types/OAuthConnection.cs rename to src/Tgstation.Server.Host/GraphQL/Types/OAuth/OAuthConnection.cs index 7b0732fe92..b073446c66 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/OAuthConnection.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/OAuth/OAuthConnection.cs @@ -2,7 +2,7 @@ using Tgstation.Server.Api.Models; -namespace Tgstation.Server.Host.GraphQL.Types +namespace Tgstation.Server.Host.GraphQL.Types.OAuth { /// /// Represents a valid OAuth connection. diff --git a/src/Tgstation.Server.Host/GraphQL/Types/OAuth/OAuthProviderInfos.cs b/src/Tgstation.Server.Host/GraphQL/Types/OAuth/OAuthProviderInfos.cs new file mode 100644 index 0000000000..da9bffe4b3 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/OAuth/OAuthProviderInfos.cs @@ -0,0 +1,66 @@ +using System; + +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Security.OAuth; + +namespace Tgstation.Server.Host.GraphQL.Types.OAuth +{ + /// + /// Description of configured OAuth services. + /// + public sealed class OAuthProviderInfos + { + /// + /// https://discord.com. + /// + public BasicOAuthProviderInfo? Discord { get; } + + /// + /// https://github.com. + /// + public RedirectOAuthProviderInfo? GitHub { get; } + + /// + /// https://tgstation13.org. + /// + public RedirectOAuthProviderInfo? TGForums { get; } + + /// + /// https://www.keycloak.org. + /// + public FullOAuthProviderInfo? Keycloak { get; } + + /// + /// https://invisioncommunity.com. + /// + public FullOAuthProviderInfo? InvisionCommunity { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The to get data from. + public OAuthProviderInfos(IOAuthProviders oAuthProviders) + { + ArgumentNullException.ThrowIfNull(oAuthProviders); + + var dic = oAuthProviders.ProviderInfos(); + + TProviderInfo? TryBuild(OAuthProvider oAuthProvider, Func contructor) + where TProviderInfo : BasicOAuthProviderInfo + { + if (dic.TryGetValue(oAuthProvider, out var providerInfo)) + { + return contructor(providerInfo); + } + + return null; + } + + Discord = TryBuild(OAuthProvider.Discord, info => new BasicOAuthProviderInfo(info)); + GitHub = TryBuild(OAuthProvider.GitHub, info => new RedirectOAuthProviderInfo(info)); + TGForums = TryBuild(OAuthProvider.TGForums, info => new RedirectOAuthProviderInfo(info)); + Keycloak = TryBuild(OAuthProvider.Keycloak, info => new FullOAuthProviderInfo(info)); + InvisionCommunity = TryBuild(OAuthProvider.InvisionCommunity, info => new FullOAuthProviderInfo(info)); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/OAuth/RedirectOAuthProviderInfo.cs b/src/Tgstation.Server.Host/GraphQL/Types/OAuth/RedirectOAuthProviderInfo.cs new file mode 100644 index 0000000000..ec0b00f6a0 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/OAuth/RedirectOAuthProviderInfo.cs @@ -0,0 +1,27 @@ +using System; + +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.GraphQL.Types.OAuth +{ + /// + /// OAuth provider info with a . + /// + public class RedirectOAuthProviderInfo : BasicOAuthProviderInfo + { + /// + /// The authentication server URL. + /// + public Uri RedirectUri { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The to build from. + public RedirectOAuthProviderInfo(OAuthProviderInfo providerInfo) + : base(providerInfo) + { + RedirectUri = providerInfo!.RedirectUri ?? throw new InvalidOperationException("RedirectUri not set!"); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/OAuth/ServerUrlOAuthProviderInfo.cs b/src/Tgstation.Server.Host/GraphQL/Types/OAuth/ServerUrlOAuthProviderInfo.cs new file mode 100644 index 0000000000..2add1c2bae --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/OAuth/ServerUrlOAuthProviderInfo.cs @@ -0,0 +1,27 @@ +using System; + +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.GraphQL.Types.OAuth +{ + /// + /// OAuth provider info with a . + /// + public sealed class ServerUrlOAuthProviderInfo : BasicOAuthProviderInfo + { + /// + /// The remote service URL. + /// + public Uri ServerUrl { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The to build from. + public ServerUrlOAuthProviderInfo(OAuthProviderInfo providerInfo) + : base(providerInfo) + { + ServerUrl = providerInfo!.ServerUrl ?? throw new InvalidOperationException("ServerUrl not set!"); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs b/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs index 6d7b0d6b5f..684beafdb8 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs @@ -5,29 +5,16 @@ namespace Tgstation.Server.Host.GraphQL.Types /// /// Represents a set of permissions for the server. /// - public sealed class PermissionSet : Entity + public sealed class PermissionSet { /// /// The for the . /// - public AdministrationRights AdministrationRights { get; } + public required AdministrationRights AdministrationRights { get; init; } /// /// The for the . /// - public InstanceManagerRights InstanceManagerRights { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The value of . - /// The value of . - public PermissionSet(long id, AdministrationRights administrationRights, InstanceManagerRights instanceManagerRights) - : base(id) - { - AdministrationRights = administrationRights; - InstanceManagerRights = instanceManagerRights; - } + public required InstanceManagerRights InstanceManagerRights { get; init; } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs b/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs new file mode 100644 index 0000000000..fcb9f088f4 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs @@ -0,0 +1,20 @@ +using System; +using System.Linq; + +using Tgstation.Server.Host.GraphQL.Interfaces; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// for accessing remote s. + /// + /// This is currently unimplemented. + public sealed class RemoteGateway : IGateway + { + /// + public GatewayInformation Information() => throw new NotImplementedException(); + + /// + public IQueryable Instances() => throw new NotImplementedException(); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs index e4ddf59dd2..286aad2adf 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs @@ -1,11 +1,17 @@ using System; using System.Collections.Generic; +using System.Linq; using HotChocolate; -using Tgstation.Server.Api.Models.Internal; + +using Microsoft.Extensions.Options; + +using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.GraphQL.Interfaces; +using Tgstation.Server.Host.Properties; +using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Swarm; -using Tgstation.Server.Host.System; namespace Tgstation.Server.Host.GraphQL.Types { @@ -15,36 +21,62 @@ namespace Tgstation.Server.Host.GraphQL.Types public sealed class ServerSwarm { /// - /// Gets the for the swarm. + /// If there is a swarm update in progress. /// - /// The to use. /// The to use. - /// A new . - public SwarmMetadata Metadata( - [Service] IAssemblyInformationProvider assemblyInformationProvider, + /// if there is an update in progress, otherwise. + [TgsGraphQLAuthorize] + public bool UpdateInProgress( [Service] IServerControl serverControl) { - ArgumentNullException.ThrowIfNull(assemblyInformationProvider); ArgumentNullException.ThrowIfNull(serverControl); - return new SwarmMetadata(assemblyInformationProvider, serverControl.UpdateInProgress); + return serverControl.UpdateInProgress; } /// - /// Gets the local . + /// Gets the swarm protocol major version in use. /// - /// A new . - public LocalServer LocalServer() => new(); + [TgsGraphQLAuthorize] + public int ProtocolMajorVersion => Version.Parse(MasterVersionsAttribute.Instance.RawSwarmProtocolVersion).Major; /// - /// Gets the for all servers in a swarm. + /// Gets the swarm's . + /// + /// A new . + [TgsGraphQLAuthorize] + public Users Users() => new(); + + /// + /// Gets the connected server. + /// + /// The containing the current . + /// The to use. + /// A new for the local node if it is part of a swarm, otherwise. + public IServerNode CurrentNode( + [Service] IOptionsSnapshot swarmConfigurationOptions, + [Service] ISwarmService swarmService) + { + ArgumentNullException.ThrowIfNull(swarmConfigurationOptions); + ArgumentNullException.ThrowIfNull(swarmService); + + var ourIdentifier = swarmConfigurationOptions.Value.Identifier; + if (ourIdentifier == null) + return new StandaloneNode(); + + return (IServerNode?)SwarmNode.GetSwarmNode(ourIdentifier, swarmService) ?? new StandaloneNode(); + } + + /// + /// Gets all servers in the swarm. /// /// The to use. - /// A of s if the local server is part of a swarm, otherwise. - public List? Servers( + /// A of s if the local node is part of a swarm, otherwise. + [TgsGraphQLAuthorize] + public List? Nodes( [Service] ISwarmService swarmService) { ArgumentNullException.ThrowIfNull(swarmService); - return swarmService.GetSwarmServers(); + return swarmService.GetSwarmServers()?.Select(x => new SwarmNode(x)).ToList(); } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/StandaloneNode.cs b/src/Tgstation.Server.Host/GraphQL/Types/StandaloneNode.cs new file mode 100644 index 0000000000..335bee3543 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/StandaloneNode.cs @@ -0,0 +1,19 @@ +using HotChocolate; + +using Microsoft.Extensions.Options; + +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.GraphQL.Interfaces; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// A not running as part of a larger . + /// + public sealed class StandaloneNode : IServerNode + { + /// + public IGateway Gateway([Service] IOptionsSnapshot swarmConfigurationOptions) + => new LocalGateway(); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs b/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs deleted file mode 100644 index 19fe192b23..0000000000 --- a/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; - -using Tgstation.Server.Api; -using Tgstation.Server.Host.Components.Interop; -using Tgstation.Server.Host.System; - -namespace Tgstation.Server.Host.GraphQL.Types -{ - /// - /// Represents information that is constant across all servers in a . - /// - public sealed class SwarmMetadata - { - /// - /// The version of the host. - /// - public Version Version { get; } - - /// - /// The version of the host. - /// - public Version ApiVersion => ApiHeaders.Version; - - /// - /// The DMAPI interop version the server uses. - /// - public Version DMApiVersion => DMApiConstants.InteropVersion; - - /// - /// If there is a server update in progress. - /// - public bool UpdateInProgress { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The used to derive the . - /// The value of . - public SwarmMetadata(IAssemblyInformationProvider assemblyInformationProvider, bool updateInProgress) - { - ArgumentNullException.ThrowIfNull(assemblyInformationProvider); - Version = assemblyInformationProvider.Version; - UpdateInProgress = updateInProgress; - } - } -} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs b/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs new file mode 100644 index 0000000000..520f8bd6c7 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs @@ -0,0 +1,106 @@ +using System; +using System.Linq; + +using HotChocolate; +using HotChocolate.Types.Relay; + +using Microsoft.Extensions.Options; + +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Internal; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.GraphQL.Interfaces; +using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Swarm; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represents a node server in a swarm. + /// + [Node] + public sealed class SwarmNode : IServerNode + { + /// + /// The node ID. + /// + [ID] + public string NodeId => Identifier; + + /// + /// The swarm server ID. + /// + public string Identifier { get; } + + /// + /// The swarm server's internal . + /// + public Uri Address { get; } + + /// + /// The swarm server's optional public address. + /// + public Uri? PublicAddress { get; } + + /// + /// Whether or not the server is the swarm's controller. + /// + public bool Controller { get; } + + /// + /// Node resolver for s. + /// + /// The . + /// The to load from. + /// A new with the matching if found, otherwise. + [TgsGraphQLAuthorize] + public static SwarmNode? GetSwarmNode( + string identifier, + [Service] ISwarmService swarmService) + { + ArgumentNullException.ThrowIfNull(identifier); + ArgumentNullException.ThrowIfNull(swarmService); + var info = swarmService + .GetSwarmServers() + ?.FirstOrDefault(x => x.Identifier == identifier); + + if (info == null) + return null; + + return new SwarmNode(info); + } + + /// + /// Initializes a new instance of the class. + /// + /// The to use for initialization. + public SwarmNode(SwarmServerInformation? nodeInformation) + { + ArgumentNullException.ThrowIfNull(nodeInformation); + + Identifier = nodeInformation.Identifier!; + Address = nodeInformation.Address!; + PublicAddress = nodeInformation.PublicAddress; + Controller = nodeInformation.Controller; + } + + /// + /// Gets the 's . + /// + /// The containing the current . + /// A new . + /// The 's . + public IGateway Gateway([Service] IOptionsSnapshot swarmConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(swarmConfigurationOptions); + + bool local = Identifier == swarmConfigurationOptions.Value.Identifier; + if (local) + return new LocalGateway(); + + throw new ErrorMessageException(ErrorCode.RemoteGatewaysNotImplemented); + + // return new RemoteGateway(); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs index 93badc8165..721fbe9eef 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -1,84 +1,171 @@ using System; -using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; +using HotChocolate; +using HotChocolate.Types.Relay; + +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.GraphQL.Interfaces; +using Tgstation.Server.Host.GraphQL.Types.OAuth; +using Tgstation.Server.Host.Models.Transformers; +using Tgstation.Server.Host.Security; + namespace Tgstation.Server.Host.GraphQL.Types { /// /// A user registered in the server. /// - public sealed class User : NamedEntity + [Node] + public sealed class User : NamedEntity, IUserName { /// /// If the is enabled since users cannot be deleted. System users cannot be disabled. /// - public bool Enabled { get; } + public required bool Enabled { get; init; } + + /// + /// The user's canonical (Uppercase) name. + /// + public required string CanonicalName { get; init; } /// /// When the was created. /// - public DateTimeOffset CreatedAt { get; } + public required DateTimeOffset CreatedAt { get; init; } /// /// The SID/UID of the on Windows/POSIX respectively. /// - public string? SystemIdentifier { get; } + public required string? SystemIdentifier { get; init; } /// /// The of the . /// - readonly long createdById; + [GraphQLIgnore] + public required long? CreatedById { get; init; } /// - /// Initializes a new instance of the class. + /// The of the . /// - /// The . - /// The . - /// The value of . - /// The value of . - /// The value of . - /// The value of . - public User( + [GraphQLIgnore] + public required long? GroupId { get; init; } + + /// + /// Node resolver for s. + /// + /// The to lookup. + /// The for the . + /// The for the operation. + /// A resulting in the queried , if present. + [TgsGraphQLAuthorize] + public static ValueTask GetUser( long id, - string name, - string? systemIdentifier, - DateTimeOffset createdAt, - long createdById, - bool enabled) - : base(id, name) + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) { - SystemIdentifier = systemIdentifier; - CreatedAt = createdAt; - this.createdById = createdById; - Enabled = enabled; + ArgumentNullException.ThrowIfNull(userAuthority); + return userAuthority.InvokeTransformableAllowMissing( + authority => authority.GetId(id, false, false, cancellationToken)); } /// /// The who created this . /// - /// A resulting in the who created this , if any. - public ValueTask CreatedBy() - => throw new NotImplementedException(); + /// The for the . + /// The for the operation. + /// The that created this , if any. + public async ValueTask CreatedBy( + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + if (!CreatedById.HasValue) + return null; + + var user = await userAuthority.InvokeTransformable(authority => authority.GetId(CreatedById.Value, false, true, cancellationToken)); + if (user.CanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) + return new UserName(user); + + return user; + } /// /// List of s associated with the user if OAuth is configured. /// - /// A resulting in a new of s for the if OAuth is configured. - public ValueTask>? OAuthConnections() - => throw new NotImplementedException(); + /// The for the . + /// The for the operation. + /// A resulting in a new of s for the if OAuth is configured. + public ValueTask OAuthConnections( + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + return userAuthority.Invoke( + authority => authority.OAuthConnections(Id, cancellationToken)); + } /// - /// The directly associated with the , if any. + /// The associated with the . /// - /// A resulting in the directly associated with the , if any. - public ValueTask PermissionSet() - => throw new NotImplementedException(); + /// The for the . + /// The for the operation. + /// A resulting in the associated with the . + public ValueTask EffectivePermissionSet( + [Service] IGraphQLAuthorityInvoker permissionSetAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(permissionSetAuthority); + + long lookupId; + PermissionSetLookupType lookupType; + if (GroupId.HasValue) + { + lookupId = GroupId.Value; + lookupType = PermissionSetLookupType.GroupId; + } + else + { + lookupId = Id; + lookupType = PermissionSetLookupType.UserId; + } + + return permissionSetAuthority.InvokeTransformable( + authority => authority.GetId(lookupId, lookupType, cancellationToken)); + } + + /// + /// The owned by the , if any. + /// + /// The for the . + /// The for the operation. + /// A resulting in the owned by the , if any. + public ValueTask OwnedPermissionSet( + [Service] IGraphQLAuthorityInvoker permissionSetAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(permissionSetAuthority); + + return permissionSetAuthority.InvokeTransformableAllowMissing( + authority => authority.GetId(Id, PermissionSetLookupType.UserId, cancellationToken)); + } /// /// The asociated with the user, if any. /// + /// The for the . + /// The for the operation. /// A resulting in the associated with the , if any. - public ValueTask Group() - => throw new NotImplementedException(); + public async ValueTask Group( + [Service] IGraphQLAuthorityInvoker userGroupAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userGroupAuthority); + if (!GroupId.HasValue) + return null; + + return await userGroupAuthority.InvokeTransformable( + authority => authority.GetId(GroupId.Value, false, cancellationToken)); + } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs index 82b6242221..2511ab438a 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs @@ -1,49 +1,77 @@ using System; -using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; +using HotChocolate; +using HotChocolate.Data; using HotChocolate.Types; +using HotChocolate.Types.Relay; +using Tgstation.Server.Host.Authority; + +using Tgstation.Server.Host.Models.Transformers; +using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.GraphQL.Types { /// /// Represents a group of s. /// + [Node] public sealed class UserGroup : NamedEntity { /// - /// The of the . + /// Node resolver for s. /// - readonly long permissionSetId; - - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The . - /// The value of . - public UserGroup( + /// The to lookup. + /// The for the . + /// The for the operation. + /// A resulting in the queried , if present. + [TgsGraphQLAuthorize] + public static ValueTask GetUserGroup( long id, - string name, - long permissionSetId) - : base(id, name) + [Service] IGraphQLAuthorityInvoker userGroupAuthority, + CancellationToken cancellationToken) { - this.permissionSetId = permissionSetId; + ArgumentNullException.ThrowIfNull(userGroupAuthority); + return userGroupAuthority.InvokeTransformableAllowMissing( + authority => authority.GetId(id, false, cancellationToken)); } /// - /// The of the . + /// The owned by the . /// - /// A resulting in the for the . - public ValueTask PermissionSet() - => throw new NotImplementedException(); + /// The for the . + /// The for the operation. + /// A resulting in the owned by the . + public async ValueTask PermissionSet( + [Service] IGraphQLAuthorityInvoker permissionSetAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(permissionSetAuthority); + + return await permissionSetAuthority.InvokeTransformable( + authority => authority.GetId(Id, PermissionSetLookupType.GroupId, cancellationToken)); + } /// - /// Gets the s in the . + /// Queries all registered s in the . /// - /// A resulting in a new of s in the . - [UsePaging(IncludeTotalCount = true)] - public List Users() - => throw new NotImplementedException(); + /// The for the . + /// A of all registered s in the . + [UsePaging] + [UseFiltering] + [UseSorting] + [TgsGraphQLAuthorize(nameof(IUserAuthority.Queryable))] + public IQueryable QueryableUsersByGroup( + [Service] IGraphQLAuthorityInvoker userAuthority) + { + ArgumentNullException.ThrowIfNull(userAuthority); + var dtoQueryable = userAuthority.InvokeTransformableQueryable( + authority => authority + .Queryable(false) + .Where(user => user.GroupId == Id)); + return dtoQueryable; + } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs new file mode 100644 index 0000000000..eb9fc04a52 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs @@ -0,0 +1,87 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Data; +using HotChocolate.Types; +using HotChocolate.Types.Relay; + +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.Models.Transformers; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Wrapper for accessing s. + /// + public sealed class UserGroups + { + /// + /// Gets the current . + /// + /// The for the . + /// A resulting in the current 's . + public ValueTask Current( + [Service] IGraphQLAuthorityInvoker userGroupAuthority) + { + ArgumentNullException.ThrowIfNull(userGroupAuthority); + return userGroupAuthority.InvokeTransformableAllowMissing(authority => authority.Read()); + } + + /// + /// Gets a by . + /// + /// The of the . + /// The for the . + /// The for the operation. + /// The represented by , if any. + [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.GetId))] + public ValueTask ById( + [ID(nameof(UserGroup))] long id, + [Service] IGraphQLAuthorityInvoker userGroupAuthority, + CancellationToken cancellationToken) + => UserGroup.GetUserGroup(id, userGroupAuthority, cancellationToken); + + /// + /// Queries all registered s. + /// + /// The for the . + /// A of all registered s. + [UsePaging] + [UseFiltering] + [UseSorting] + [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.Queryable))] + public IQueryable QueryableGroups( + [Service] IGraphQLAuthorityInvoker userGroupAuthority) + { + ArgumentNullException.ThrowIfNull(userGroupAuthority); + var dtoQueryable = userGroupAuthority.InvokeTransformableQueryable(authority => authority.Queryable(false)); + return dtoQueryable; + } + + /// + /// Queries all registered s in a indicated by . + /// + /// The . + /// The for the . + /// A of all registered s in the indicated by . + [UsePaging] + [UseFiltering] + [UseSorting] + [TgsGraphQLAuthorize(nameof(IUserAuthority.Queryable))] + public IQueryable QueryableUsersByGroupId( + [ID(nameof(UserGroup))]long groupId, + [Service] IGraphQLAuthorityInvoker userAuthority) + { + ArgumentNullException.ThrowIfNull(userAuthority); + var dtoQueryable = userAuthority.InvokeTransformableQueryable( + authority => authority + .Queryable(false) + .Where(user => user.GroupId == groupId)); + return dtoQueryable; + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs new file mode 100644 index 0000000000..7175c2935e --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs @@ -0,0 +1,57 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Types.Relay; + +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.GraphQL.Interfaces; +using Tgstation.Server.Host.Models.Transformers; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// A with limited fields. + /// + [Node] + public sealed class UserName : NamedEntity, IUserName + { + /// + /// Node resolver for s. + /// + /// The to lookup. + /// The for the . + /// The for the operation. + /// A resulting in the queried , if present. + [TgsGraphQLAuthorize] + public static ValueTask GetUserName( + long id, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + return userAuthority.InvokeTransformableAllowMissing( + authority => authority.GetId(id, false, true, cancellationToken)); + } + + /// + /// Initializes a new instance of the class. + /// + /// The to copy. + [SetsRequiredMembers] + public UserName(NamedEntity copy) + : base(copy) + { + } + + /// + /// Initializes a new instance of the class. + /// + public UserName() + { + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs new file mode 100644 index 0000000000..04ac751b25 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs @@ -0,0 +1,77 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Data; +using HotChocolate.Types; +using HotChocolate.Types.Relay; + +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.Models.Transformers; +using Tgstation.Server.Host.Security; + +#pragma warning disable CA1724 // conflict with GitLabApiClient.Models.Users. They can fuck off + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Wrapper for accessing s. + /// + public sealed class Users + { + /// + /// Gets the swarm's . + /// + /// A new . + public UserGroups Groups() => new(); + + /// + /// Gets the current . + /// + /// The for the . + /// The for the operation. + /// A resulting in the current . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Read))] + public ValueTask Current( + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + return userAuthority.InvokeTransformable(authority => authority.Read(cancellationToken)); + } + + /// + /// Gets a by . + /// + /// The of the . + /// The for the . + /// The for the operation. + /// The represented by , if any. + [Error(typeof(ErrorMessageException))] + [TgsGraphQLAuthorize(nameof(IUserAuthority.GetId))] + public ValueTask ById( + [ID(nameof(User))] long id, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + => User.GetUser(id, userAuthority, cancellationToken); + + /// + /// Queries all registered s. + /// + /// The for the . + /// A of all registered s. + [UsePaging] + [UseFiltering] + [UseSorting] + [TgsGraphQLAuthorize(nameof(IUserAuthority.Queryable))] + public IQueryable QueryableUsers( + [Service] IGraphQLAuthorityInvoker userAuthority) + { + ArgumentNullException.ThrowIfNull(userAuthority); + var dtoQueryable = userAuthority.InvokeTransformableQueryable(authority => authority.Queryable(false)); + return dtoQueryable; + } + } +} diff --git a/src/Tgstation.Server.Host/Models/ChatBot.cs b/src/Tgstation.Server.Host/Models/ChatBot.cs index caf1f0c645..908581eff5 100644 --- a/src/Tgstation.Server.Host/Models/ChatBot.cs +++ b/src/Tgstation.Server.Host/Models/ChatBot.cs @@ -8,7 +8,7 @@ using Tgstation.Server.Api.Models.Response; namespace Tgstation.Server.Host.Models { /// - public sealed class ChatBot : Api.Models.Internal.ChatBotSettings, IApiTransformable + public sealed class ChatBot : Api.Models.Internal.ChatBotSettings, ILegacyApiTransformable { /// /// Default for . diff --git a/src/Tgstation.Server.Host/Models/CompileJob.cs b/src/Tgstation.Server.Host/Models/CompileJob.cs index 1ce860be6e..d316f18bd3 100644 --- a/src/Tgstation.Server.Host/Models/CompileJob.cs +++ b/src/Tgstation.Server.Host/Models/CompileJob.cs @@ -7,7 +7,7 @@ using Tgstation.Server.Api.Models.Response; namespace Tgstation.Server.Host.Models { /// - public sealed class CompileJob : Api.Models.Internal.CompileJob, IApiTransformable + public sealed class CompileJob : Api.Models.Internal.CompileJob, ILegacyApiTransformable { /// /// See . diff --git a/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs b/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs index 5e55dc9683..3904c8ad5d 100644 --- a/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs +++ b/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs @@ -5,7 +5,7 @@ using Tgstation.Server.Api.Models.Response; namespace Tgstation.Server.Host.Models { /// - public sealed class DreamMakerSettings : Api.Models.Internal.DreamMakerSettings, IApiTransformable + public sealed class DreamMakerSettings : Api.Models.Internal.DreamMakerSettings, ILegacyApiTransformable { /// /// The row Id. diff --git a/src/Tgstation.Server.Host/Models/IApiTransformable{TModel,TApiModel,TTransformer}.cs b/src/Tgstation.Server.Host/Models/IApiTransformable{TModel,TApiModel,TTransformer}.cs new file mode 100644 index 0000000000..0fff10e229 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/IApiTransformable{TModel,TApiModel,TTransformer}.cs @@ -0,0 +1,26 @@ +using System; + +#pragma warning disable CA1005 + +namespace Tgstation.Server.Host.Models +{ + /// + /// Represents a host-side model that may be transformed into a . + /// + /// The internal model . + /// The API model . + /// The . + public interface IApiTransformable + where TApiModel : notnull + where TModel : IApiTransformable + where TTransformer : ITransformer, new() + { + /// + /// Convert the to it's . + /// + /// A new based on the . + TApiModel ToApi() + => new TTransformer() + .CompiledExpression((TModel)this); + } +} diff --git a/src/Tgstation.Server.Host/Models/IApiTransformable.cs b/src/Tgstation.Server.Host/Models/ILegacyApiTransformable{TApiModel}.cs similarity index 61% rename from src/Tgstation.Server.Host/Models/IApiTransformable.cs rename to src/Tgstation.Server.Host/Models/ILegacyApiTransformable{TApiModel}.cs index e2b807b62e..b8064202d8 100644 --- a/src/Tgstation.Server.Host/Models/IApiTransformable.cs +++ b/src/Tgstation.Server.Host/Models/ILegacyApiTransformable{TApiModel}.cs @@ -4,12 +4,12 @@ /// Represents a host-side model that may be transformed into a . /// /// The API form of the model. - public interface IApiTransformable + public interface ILegacyApiTransformable { /// - /// Convert the to it's . + /// Convert the to it's . /// - /// A new based on the . + /// A new based on the . TApiModel ToApi(); } } diff --git a/src/Tgstation.Server.Host/Models/ITransformer{TInput,TOutput}.cs b/src/Tgstation.Server.Host/Models/ITransformer{TInput,TOutput}.cs new file mode 100644 index 0000000000..9da15bcfb7 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/ITransformer{TInput,TOutput}.cs @@ -0,0 +1,23 @@ +using System; +using System.Linq.Expressions; + +namespace Tgstation.Server.Host.Models +{ + /// + /// Contains a transformation for converting s to s. + /// + /// The input . + /// The output . + public interface ITransformer + { + /// + /// form of the transformation. + /// + Expression> Expression { get; } + + /// + /// The compiled . + /// + Func CompiledExpression { get; } + } +} diff --git a/src/Tgstation.Server.Host/Models/Instance.cs b/src/Tgstation.Server.Host/Models/Instance.cs index 35434d81e1..ded8b8fd55 100644 --- a/src/Tgstation.Server.Host/Models/Instance.cs +++ b/src/Tgstation.Server.Host/Models/Instance.cs @@ -7,7 +7,7 @@ namespace Tgstation.Server.Host.Models /// /// Represents an in the database. /// - public sealed class Instance : Api.Models.Instance, IApiTransformable + public sealed class Instance : Api.Models.Instance, ILegacyApiTransformable { /// /// Default for . diff --git a/src/Tgstation.Server.Host/Models/InstancePermissionSet.cs b/src/Tgstation.Server.Host/Models/InstancePermissionSet.cs index 8db1f17fba..be4e5357d1 100644 --- a/src/Tgstation.Server.Host/Models/InstancePermissionSet.cs +++ b/src/Tgstation.Server.Host/Models/InstancePermissionSet.cs @@ -5,7 +5,7 @@ using Tgstation.Server.Api.Models.Response; namespace Tgstation.Server.Host.Models { /// - public sealed class InstancePermissionSet : Api.Models.Internal.InstancePermissionSet, IApiTransformable + public sealed class InstancePermissionSet : Api.Models.Internal.InstancePermissionSet, ILegacyApiTransformable { /// /// The row Id. diff --git a/src/Tgstation.Server.Host/Models/Job.cs b/src/Tgstation.Server.Host/Models/Job.cs index 85327de2e2..521e671be8 100644 --- a/src/Tgstation.Server.Host/Models/Job.cs +++ b/src/Tgstation.Server.Host/Models/Job.cs @@ -11,7 +11,7 @@ namespace Tgstation.Server.Host.Models { /// #pragma warning disable CA1724 // naming conflict with gitlab package - public sealed class Job : Api.Models.Internal.Job, IApiTransformable + public sealed class Job : Api.Models.Internal.Job, ILegacyApiTransformable #pragma warning restore CA1724 { /// diff --git a/src/Tgstation.Server.Host/Models/OAuthConnection.cs b/src/Tgstation.Server.Host/Models/OAuthConnection.cs index d622b358c8..7c871b672a 100644 --- a/src/Tgstation.Server.Host/Models/OAuthConnection.cs +++ b/src/Tgstation.Server.Host/Models/OAuthConnection.cs @@ -1,13 +1,18 @@ namespace Tgstation.Server.Host.Models { /// - public sealed class OAuthConnection : Api.Models.OAuthConnection, IApiTransformable + public sealed class OAuthConnection : Api.Models.OAuthConnection, ILegacyApiTransformable { /// /// The row Id. /// public long Id { get; set; } + /// + /// The of . + /// + public long UserId { get; set; } + /// /// The owning . /// diff --git a/src/Tgstation.Server.Host/Models/PermissionSet.cs b/src/Tgstation.Server.Host/Models/PermissionSet.cs index 2c18a2ae1f..94b769e4a8 100644 --- a/src/Tgstation.Server.Host/Models/PermissionSet.cs +++ b/src/Tgstation.Server.Host/Models/PermissionSet.cs @@ -1,9 +1,11 @@ using System.Collections.Generic; +using Tgstation.Server.Host.Models.Transformers; + namespace Tgstation.Server.Host.Models { /// - public sealed class PermissionSet : Api.Models.PermissionSet + public sealed class PermissionSet : Api.Models.PermissionSet, IApiTransformable { /// /// The of . diff --git a/src/Tgstation.Server.Host/Models/RepositorySettings.cs b/src/Tgstation.Server.Host/Models/RepositorySettings.cs index d804162718..9f36e2f123 100644 --- a/src/Tgstation.Server.Host/Models/RepositorySettings.cs +++ b/src/Tgstation.Server.Host/Models/RepositorySettings.cs @@ -5,7 +5,7 @@ using Tgstation.Server.Api.Models.Response; namespace Tgstation.Server.Host.Models { /// - public sealed class RepositorySettings : Api.Models.RepositorySettings, IApiTransformable + public sealed class RepositorySettings : Api.Models.RepositorySettings, ILegacyApiTransformable { /// /// The row Id. diff --git a/src/Tgstation.Server.Host/Models/RevisionInformation.cs b/src/Tgstation.Server.Host/Models/RevisionInformation.cs index eb99816ab6..d683e9c8b7 100644 --- a/src/Tgstation.Server.Host/Models/RevisionInformation.cs +++ b/src/Tgstation.Server.Host/Models/RevisionInformation.cs @@ -6,7 +6,7 @@ using System.Linq; namespace Tgstation.Server.Host.Models { /// - public sealed class RevisionInformation : Api.Models.Internal.RevisionInformation, IApiTransformable + public sealed class RevisionInformation : Api.Models.Internal.RevisionInformation, ILegacyApiTransformable { /// /// The row Id. diff --git a/src/Tgstation.Server.Host/Models/TestMerge.cs b/src/Tgstation.Server.Host/Models/TestMerge.cs index 3641640378..1aaf31594a 100644 --- a/src/Tgstation.Server.Host/Models/TestMerge.cs +++ b/src/Tgstation.Server.Host/Models/TestMerge.cs @@ -5,7 +5,7 @@ using System.ComponentModel.DataAnnotations; namespace Tgstation.Server.Host.Models { /// - public sealed class TestMerge : Api.Models.Internal.TestMergeApiBase, IApiTransformable + public sealed class TestMerge : Api.Models.Internal.TestMergeApiBase, ILegacyApiTransformable { /// /// See . diff --git a/src/Tgstation.Server.Host/Models/Transformers/PermissionSetGraphQLTransformer.cs b/src/Tgstation.Server.Host/Models/Transformers/PermissionSetGraphQLTransformer.cs new file mode 100644 index 0000000000..49f70f462f --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Transformers/PermissionSetGraphQLTransformer.cs @@ -0,0 +1,20 @@ +namespace Tgstation.Server.Host.Models.Transformers +{ + /// + /// for s. + /// + sealed class PermissionSetGraphQLTransformer : TransformerBase + { + /// + /// Initializes a new instance of the class. + /// + public PermissionSetGraphQLTransformer() + : base(model => new GraphQL.Types.PermissionSet + { + AdministrationRights = model.AdministrationRights!.Value, + InstanceManagerRights = model.InstanceManagerRights!.Value, + }) + { + } + } +} diff --git a/src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TInput,TOutput}.cs b/src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TInput,TOutput}.cs new file mode 100644 index 0000000000..e3d16441cc --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TInput,TOutput}.cs @@ -0,0 +1,32 @@ +using System; +using System.Linq.Expressions; + +namespace Tgstation.Server.Host.Models.Transformers +{ + /// + abstract class TransformerBase : ITransformer + { + /// + /// cache for . + /// + static Func? compiledExpression; + + /// + public Expression> Expression { get; } + + /// + public Func CompiledExpression { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + protected TransformerBase( + Expression> expression) + { + compiledExpression ??= expression.Compile(); + Expression = expression; + CompiledExpression = compiledExpression; + } + } +} diff --git a/src/Tgstation.Server.Host/Models/Transformers/UserGraphQLTransformer.cs b/src/Tgstation.Server.Host/Models/Transformers/UserGraphQLTransformer.cs new file mode 100644 index 0000000000..9c1184ecce --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Transformers/UserGraphQLTransformer.cs @@ -0,0 +1,26 @@ +namespace Tgstation.Server.Host.Models.Transformers +{ + /// + /// for s. + /// + sealed class UserGraphQLTransformer : TransformerBase + { + /// + /// Initializes a new instance of the class. + /// + public UserGraphQLTransformer() + : base(model => new GraphQL.Types.User + { + CreatedAt = model.CreatedAt!.Value, + CanonicalName = model.CanonicalName!, + CreatedById = model.CreatedById, + Enabled = model.Enabled!.Value, + GroupId = model.GroupId, + Id = model.Id!.Value, + Name = model.Name!, + SystemIdentifier = model.SystemIdentifier, + }) + { + } + } +} diff --git a/src/Tgstation.Server.Host/Models/Transformers/UserGroupGraphQLTransformer.cs b/src/Tgstation.Server.Host/Models/Transformers/UserGroupGraphQLTransformer.cs new file mode 100644 index 0000000000..26ce4752e0 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Transformers/UserGroupGraphQLTransformer.cs @@ -0,0 +1,20 @@ +namespace Tgstation.Server.Host.Models.Transformers +{ + /// + /// for s. + /// + sealed class UserGroupGraphQLTransformer : TransformerBase + { + /// + /// Initializes a new instance of the class. + /// + public UserGroupGraphQLTransformer() + : base(model => new GraphQL.Types.UserGroup + { + Id = model.Id!.Value, + Name = model.Name!, + }) + { + } + } +} diff --git a/src/Tgstation.Server.Host/Models/Transformers/UserNameGraphQLTransformer.cs b/src/Tgstation.Server.Host/Models/Transformers/UserNameGraphQLTransformer.cs new file mode 100644 index 0000000000..ebe6338895 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Transformers/UserNameGraphQLTransformer.cs @@ -0,0 +1,20 @@ +namespace Tgstation.Server.Host.Models.Transformers +{ + /// + /// for s. + /// + sealed class UserNameGraphQLTransformer : TransformerBase + { + /// + /// Initializes a new instance of the class. + /// + public UserNameGraphQLTransformer() + : base(model => new GraphQL.Types.UserName + { + Id = model.Id!.Value, + Name = model.Name!, + }) + { + } + } +} diff --git a/src/Tgstation.Server.Host/Models/User.cs b/src/Tgstation.Server.Host/Models/User.cs index 0939dd8042..c573368deb 100644 --- a/src/Tgstation.Server.Host/Models/User.cs +++ b/src/Tgstation.Server.Host/Models/User.cs @@ -5,11 +5,15 @@ using System.Linq; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Models.Transformers; namespace Tgstation.Server.Host.Models { /// - public sealed class User : Api.Models.Internal.UserModelBase, IApiTransformable + public sealed class User : Api.Models.Internal.UserModelBase, + ILegacyApiTransformable, + IApiTransformable, + IApiTransformable { /// /// Username used when creating jobs automatically. @@ -26,13 +30,18 @@ namespace Tgstation.Server.Host.Models /// public User? CreatedBy { get; set; } + /// + /// The of the 's . + /// + public long? CreatedById { get; set; } + /// /// The the belongs to, if any. /// public UserGroup? Group { get; set; } /// - /// The ID of the 's . + /// The of the 's . /// public long? GroupId { get; set; } diff --git a/src/Tgstation.Server.Host/Models/UserGroup.cs b/src/Tgstation.Server.Host/Models/UserGroup.cs index 4451c1a073..6a8e21c18c 100644 --- a/src/Tgstation.Server.Host/Models/UserGroup.cs +++ b/src/Tgstation.Server.Host/Models/UserGroup.cs @@ -5,13 +5,14 @@ using System.Linq; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Models.Transformers; namespace Tgstation.Server.Host.Models { /// /// Represents a group of s. /// - public sealed class UserGroup : NamedEntity, IApiTransformable + public sealed class UserGroup : NamedEntity, ILegacyApiTransformable, IApiTransformable { /// /// The the has. diff --git a/src/Tgstation.Server.Host/Program.cs b/src/Tgstation.Server.Host/Program.cs index b23633b7df..96f1e4b50c 100644 --- a/src/Tgstation.Server.Host/Program.cs +++ b/src/Tgstation.Server.Host/Program.cs @@ -44,33 +44,46 @@ namespace Tgstation.Server.Host public static async Task Main(string[] args) { // first arg is 100% always the update path, starting it otherwise is solely for debugging purposes - string? updatePath = null; - if (args.Length > 0) - { - var listArgs = new List(args); - updatePath = listArgs.First(); - listArgs.RemoveAt(0); - - // second arg should be host watchdog version - if (listArgs.Count > 0) - { - var expectedHostWatchdogVersion = HostWatchdogVersion; - if (Version.TryParse(listArgs.First(), out var actualHostWatchdogVersion) - && actualHostWatchdogVersion.Major != expectedHostWatchdogVersion.Major) - throw new InvalidOperationException( - $"Incompatible host watchdog version ({actualHostWatchdogVersion}) for server ({expectedHostWatchdogVersion})! A major update was released and a full restart will be required. Please manually offline your servers!"); - } - - if (listArgs.Remove("--attach-debugger")) - Debugger.Launch(); - - args = listArgs.ToArray(); - } + var updatePath = TopLevelArgsParse(ref args); var program = new Program(); return (int)await program.Main(args, updatePath); } + /// + /// Parse top level . + /// + /// The arguments which may be changed. + /// The update path for the server, if present. + static string? TopLevelArgsParse(ref string[] args) + { + if (args.Length == 0) + return null; + + var potentialUpdatePath = args[0]; + if (potentialUpdatePath.Equals("cli", StringComparison.OrdinalIgnoreCase)) + return null; + + var listArgs = new List(args); + listArgs.RemoveAt(0); + + // second arg should be host watchdog version + if (listArgs.Count > 0) + { + var expectedHostWatchdogVersion = HostWatchdogVersion; + if (Version.TryParse(listArgs.First(), out var actualHostWatchdogVersion) + && actualHostWatchdogVersion.Major != expectedHostWatchdogVersion.Major) + throw new InvalidOperationException( + $"Incompatible host watchdog version ({actualHostWatchdogVersion}) for server ({expectedHostWatchdogVersion})! A major update was released and a full restart will be required. Please manually offline your servers!"); + } + + if (listArgs.Remove("--attach-debugger")) + Debugger.Launch(); + + args = [.. listArgs]; + return potentialUpdatePath; + } + /// /// Executes the . /// diff --git a/src/Tgstation.Server.Host/Properties/AssemblyInfo.cs b/src/Tgstation.Server.Host/Properties/AssemblyInfo.cs index 47d191b80a..23e85d3ef1 100644 --- a/src/Tgstation.Server.Host/Properties/AssemblyInfo.cs +++ b/src/Tgstation.Server.Host/Properties/AssemblyInfo.cs @@ -1,6 +1,10 @@ using System.Runtime.CompilerServices; +using GreenDonut; + [assembly: InternalsVisibleTo("Tgstation.Server.Host.Tests")] [assembly: InternalsVisibleTo("Tgstation.Server.Host.Tests.Signals")] [assembly: InternalsVisibleTo("Tgstation.Server.Tests")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] + +[assembly: DataLoaderDefaults(AccessModifier = DataLoaderAccessModifier.Internal)] diff --git a/src/Tgstation.Server.Host/Properties/MasterVersionsAttribute.cs b/src/Tgstation.Server.Host/Properties/MasterVersionsAttribute.cs index 8212c4ffee..efef0144bc 100644 --- a/src/Tgstation.Server.Host/Properties/MasterVersionsAttribute.cs +++ b/src/Tgstation.Server.Host/Properties/MasterVersionsAttribute.cs @@ -32,7 +32,7 @@ namespace Tgstation.Server.Host.Properties public string RawWebpanelVersion { get; } /// - /// The of the control panel version built. + /// The of the host watchdog that was built alongside this TGS version. /// public string RawHostWatchdogVersion { get; } @@ -42,10 +42,15 @@ namespace Tgstation.Server.Host.Properties public string RawMariaDBRedistVersion { get; } /// - /// The of the MariaDB server bundled with TGS installs. + /// The of the TGS swarm protocol. /// public string RawSwarmProtocolVersion { get; } + /// + /// The of the TGS GraphQL API. + /// + public string RawGraphQLVersion { get; } + /// /// Initializes a new instance of the class. /// @@ -55,13 +60,15 @@ namespace Tgstation.Server.Host.Properties /// The value of . /// The value of . /// The value of . + /// The value of . public MasterVersionsAttribute( string rawConfigurationVersion, string rawInteropVersion, string rawWebpanelVersion, string rawHostWatchdogVersion, string rawMariaDBRedistVersion, - string rawSwarmProtocolVersion) + string rawSwarmProtocolVersion, + string rawGraphQLVersion) { RawConfigurationVersion = rawConfigurationVersion ?? throw new ArgumentNullException(nameof(rawConfigurationVersion)); RawInteropVersion = rawInteropVersion ?? throw new ArgumentNullException(nameof(rawInteropVersion)); @@ -69,6 +76,7 @@ namespace Tgstation.Server.Host.Properties RawHostWatchdogVersion = rawHostWatchdogVersion ?? throw new ArgumentNullException(nameof(rawHostWatchdogVersion)); RawMariaDBRedistVersion = rawMariaDBRedistVersion ?? throw new ArgumentNullException(nameof(rawMariaDBRedistVersion)); RawSwarmProtocolVersion = rawSwarmProtocolVersion ?? throw new ArgumentNullException(nameof(rawSwarmProtocolVersion)); + RawGraphQLVersion = rawGraphQLVersion ?? throw new ArgumentNullException(nameof(rawGraphQLVersion)); } } } diff --git a/src/Tgstation.Server.Host/README.md b/src/Tgstation.Server.Host/README.md index 1f8df0feee..9dd86b2d63 100644 --- a/src/Tgstation.Server.Host/README.md +++ b/src/Tgstation.Server.Host/README.md @@ -23,13 +23,15 @@ Server startup can be a bit complicated so here's a walkthrough Here's a breakdown of things in this directory - [.config](./.config) contains the dotnet-tools.json. At the time of writing, this is only used to set the version of the [dotnet ef tools](https://docs.microsoft.com/en-us/ef/core/miscellaneous/cli/) used to create database migrations. +- [Authority](./Authority) is code that contains the buisiness logic that bridges the HTTP APIs with component code and the database. - [ClientApp](./ClientApp) contains scripts to build and deploy the web control panel with TGS. - [Components](./Components) is where the bulk of the TGS implementation lives. - [Configuration](./Configuration) contains classes that partly make up the configuration yaml files (i.e. [appsettings.yml](./appsettings.yml)). -- [Controllers](./Controllers) is where HTTP API code lives and bridges it with component code. +- [Controllers](./Controllers) is where REST API code lives. - [Core](./Core) contains [Application.cs](./Core/Application.cs) and other classes related to the overrarching server process. - [Database](./Database) contains all database related code. - [Extensions](./Extensions) contains helper functions implemented as C# extension methods. +- [GraphQL](./GraqhQL) is where GraphQL API code lives. - [IO](./IO) contains classes related to interacting with the filesystem. - [Jobs](./Jobs) contains the job manager code. - [Models](./Models) contains ORM models used to interact with the database. diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContext.cs b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs index 53bf23c2a2..8659db7d56 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContext.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs @@ -13,10 +13,10 @@ namespace Tgstation.Server.Host.Security public bool Valid { get; private set; } /// - public User User => user ?? throw new InvalidOperationException("AuthenticationContext is invalid!"); + public User User => user ?? throw InvalidContext(); /// - public PermissionSet PermissionSet => permissionSet ?? throw new InvalidOperationException("AuthenticationContext is invalid!"); + public PermissionSet PermissionSet => permissionSet ?? throw InvalidContext(); /// public InstancePermissionSet? InstancePermissionSet { get; private set; } @@ -24,6 +24,12 @@ namespace Tgstation.Server.Host.Security /// public ISystemIdentity? SystemIdentity { get; private set; } + /// + public DateTimeOffset SessionExpiry => sessionExpiry ?? throw InvalidContext(); + + /// + public string SessionId => sessionId ?? throw InvalidContext(); + /// /// Backing field for . /// @@ -34,6 +40,16 @@ namespace Tgstation.Server.Host.Security /// PermissionSet? permissionSet; + /// + /// Backing field for . + /// + DateTimeOffset? sessionExpiry; + + /// + /// Backing field for . + /// + string? sessionId; + /// /// Initializes a new instance of the class. /// @@ -41,25 +57,44 @@ namespace Tgstation.Server.Host.Security { } + /// + /// for accessing fields on an In . + /// + /// A new . + static InvalidOperationException InvalidContext() + => new("AuthenticationContext is invalid!"); + /// public void Dispose() => SystemIdentity?.Dispose(); /// /// Initializes the . /// - /// The value of . /// The value of . + /// The value of . + /// The value of . /// The value of . - public void Initialize(ISystemIdentity? systemIdentity, User user, InstancePermissionSet? instanceUser) + /// The value of . + public void Initialize( + User user, + DateTimeOffset sessionExpiry, + string sessionId, + InstancePermissionSet? instanceUser, + ISystemIdentity? systemIdentity) { - this.user = user ?? throw new ArgumentNullException(nameof(user)); - if (systemIdentity == null && User.SystemIdentifier != null) + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(sessionId); + if (systemIdentity == null && user.SystemIdentifier != null) throw new ArgumentNullException(nameof(systemIdentity)); + permissionSet = user.PermissionSet ?? user.Group!.PermissionSet ?? throw new ArgumentException("No PermissionSet provider", nameof(user)); + this.user = user; InstancePermissionSet = instanceUser; SystemIdentity = systemIdentity; + this.sessionId = sessionId; + this.sessionExpiry = sessionExpiry; Valid = true; } diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs index d649e2f7ff..d8a47f782f 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs @@ -1,17 +1,12 @@ using System; using System.Collections.Generic; -using System.Globalization; -using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; -using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; -using Microsoft.IdentityModel.Tokens; -using Tgstation.Server.Api; using Tgstation.Server.Api.Rights; -using Tgstation.Server.Host.Utils; +using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Security { @@ -21,76 +16,40 @@ namespace Tgstation.Server.Host.Security sealed class AuthenticationContextClaimsTransformation : IClaimsTransformation { /// - /// The for the . + /// The for the . /// - readonly IAuthenticationContextFactory authenticationContextFactory; - - /// - /// The for the . - /// - readonly ApiHeaders? apiHeaders; + readonly IAuthenticationContext authenticationContext; /// /// Initializes a new instance of the class. /// - /// The value of . - /// The containing the value of . - public AuthenticationContextClaimsTransformation(IAuthenticationContextFactory authenticationContextFactory, IApiHeadersProvider apiHeadersProvider) + /// The value of . + public AuthenticationContextClaimsTransformation(IAuthenticationContext authenticationContext) { - this.authenticationContextFactory = authenticationContextFactory ?? throw new ArgumentNullException(nameof(authenticationContextFactory)); - ArgumentNullException.ThrowIfNull(apiHeadersProvider); - apiHeaders = apiHeadersProvider.ApiHeaders; + this.authenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); } /// - public async Task TransformAsync(ClaimsPrincipal principal) + public Task TransformAsync(ClaimsPrincipal principal) { ArgumentNullException.ThrowIfNull(principal); - var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub); - if (userIdClaim == default) - throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Sub}' claim!"); - - long userId; - try - { - userId = Int64.Parse(userIdClaim.Value, CultureInfo.InvariantCulture); - } - catch (Exception e) - { - throw new InvalidOperationException("Failed to parse user ID!", e); - } - - var nbfClaim = principal.FindFirst(JwtRegisteredClaimNames.Nbf); - if (nbfClaim == default) - throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Nbf}' claim!"); - - DateTimeOffset nbf; - try - { - nbf = new DateTimeOffset( - EpochTime.DateTime( - Int64.Parse(nbfClaim.Value, CultureInfo.InvariantCulture))); - } - catch (Exception ex) - { - throw new InvalidOperationException("Failed to parse nbf!", ex); - } - - var authenticationContext = await authenticationContextFactory.CreateAuthenticationContext( - userId, - apiHeaders?.InstanceId, - nbf, - CancellationToken.None); // DCT: None available + if (!authenticationContext.Valid) + throw new InvalidOperationException("Expected a valid authentication context here!"); var enumerator = Enum.GetValues(typeof(RightsType)); var claims = new List(); + if (authenticationContext.User.Require(x => x.Enabled)) + claims.Add( + new Claim( + ClaimTypes.Role, + TgsAuthorizeAttribute.UserEnabledRole)); + foreach (RightsType rightType in enumerator) { // if there's a bad condition, do a weird thing and add all the roles // we need it so we can get to TgsAuthorizeAttribute where we can properly decide between BadRequest and Forbid - var rightAsULong = !authenticationContext.Valid - || (RightsHelper.IsInstanceRight(rightType) && authenticationContext.InstancePermissionSet == null) + var rightAsULong = (RightsHelper.IsInstanceRight(rightType) && authenticationContext.InstancePermissionSet == null) ? ~0UL : authenticationContext.GetRight(rightType); var rightEnum = RightsHelper.RightToType(rightType); @@ -105,7 +64,7 @@ namespace Tgstation.Server.Host.Security principal.AddIdentity(new ClaimsIdentity(claims)); - return principal; + return Task.FromResult(principal); } } } diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs index 00df5df54f..ba47b47a44 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs @@ -1,20 +1,27 @@ using System; +using System.Globalization; using System.Linq; +using System.Security.Claims; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Tgstation.Server.Api; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Utils; namespace Tgstation.Server.Host.Security { /// - sealed class AuthenticationContextFactory : IAuthenticationContextFactory, IDisposable + sealed class AuthenticationContextFactory : ITokenValidator, IDisposable { /// /// The the created. @@ -46,6 +53,11 @@ namespace Tgstation.Server.Host.Security /// readonly AuthenticationContext currentAuthenticationContext; + /// + /// The for the . + /// + readonly ApiHeaders? apiHeaders; + /// /// 1 if was initialized, 0 otherwise. /// @@ -56,16 +68,21 @@ namespace Tgstation.Server.Host.Security /// /// The value of . /// The value of . + /// The containing the value of . /// The containing the value of . /// The value of . public AuthenticationContextFactory( IDatabaseContext databaseContext, IIdentityCache identityCache, + IApiHeadersProvider apiHeadersProvider, IOptions swarmConfigurationOptions, ILogger logger) { this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache)); + ArgumentNullException.ThrowIfNull(apiHeadersProvider); + + apiHeaders = apiHeadersProvider.ApiHeaders; swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -76,11 +93,55 @@ namespace Tgstation.Server.Host.Security public void Dispose() => currentAuthenticationContext.Dispose(); /// - public async ValueTask CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset notBefore, CancellationToken cancellationToken) + #pragma warning disable CA1506 // TODO: Decomplexify + public async Task ValidateToken(TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken) + #pragma warning restore CA1506 { + ArgumentNullException.ThrowIfNull(tokenValidatedContext); + + if (tokenValidatedContext.SecurityToken is not JsonWebToken jwt) + throw new ArgumentException($"Expected {nameof(tokenValidatedContext)} to contain a {nameof(JsonWebToken)}!", nameof(tokenValidatedContext)); + if (Interlocked.Exchange(ref initialized, 1) != 0) throw new InvalidOperationException("Authentication context has already been loaded"); + var principal = new ClaimsPrincipal(new ClaimsIdentity(jwt.Claims)); + + var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub); + if (userIdClaim == default) + throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Sub}' claim!"); + + long userId; + try + { + userId = Int64.Parse(userIdClaim.Value, CultureInfo.InvariantCulture); + } + catch (Exception e) + { + throw new InvalidOperationException("Failed to parse user ID!", e); + } + + DateTimeOffset ParseTime(string key) + { + var claim = principal.FindFirst(key); + if (claim == default) + throw new InvalidOperationException($"Missing '{key}' claim!"); + + try + { + return new DateTimeOffset( + EpochTime.DateTime( + Int64.Parse(claim.Value, CultureInfo.InvariantCulture))); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to parse '{key}'!", ex); + } + } + + var notBefore = ParseTime(JwtRegisteredClaimNames.Nbf); + var expires = ParseTime(JwtRegisteredClaimNames.Exp); + var user = await databaseContext .Users .AsQueryable() @@ -93,8 +154,8 @@ namespace Tgstation.Server.Host.Security .FirstOrDefaultAsync(cancellationToken); if (user == default) { - logger.LogWarning("Unable to find user with ID {userId}!", userId); - return currentAuthenticationContext; + tokenValidatedContext.Fail($"Unable to find user with ID {userId}!"); + return; } ISystemIdentity? systemIdentity; @@ -104,8 +165,8 @@ namespace Tgstation.Server.Host.Security { if (user.LastPasswordUpdate.HasValue && user.LastPasswordUpdate >= notBefore) { - logger.LogDebug("Rejecting token for user {userId} created before last password update: {lastPasswordUpdate}", userId, user.LastPasswordUpdate.Value); - return currentAuthenticationContext; + tokenValidatedContext.Fail($"Rejecting token for user {userId} created before last modification: {user.LastPasswordUpdate.Value}"); + return; } systemIdentity = null; @@ -115,6 +176,7 @@ namespace Tgstation.Server.Host.Security try { InstancePermissionSet? instancePermissionSet = null; + var instanceId = apiHeaders?.InstanceId; if (instanceId.HasValue) { instancePermissionSet = await databaseContext.InstancePermissionSets @@ -128,10 +190,11 @@ namespace Tgstation.Server.Host.Security } currentAuthenticationContext.Initialize( - systemIdentity, user, - instancePermissionSet); - return currentAuthenticationContext; + expires, + jwt.EncodedSignature, // signature is enough to uniquely identify the session as it is composite of all the inputs + instancePermissionSet, + systemIdentity); } catch { diff --git a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs index 8277a6601d..d827185dd2 100644 --- a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs +++ b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs @@ -1,17 +1,29 @@ -using Tgstation.Server.Api.Rights; +using System; + +using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Security { /// - /// Represents the currently authenticated . + /// For creating and accessing authentication contexts. /// public interface IAuthenticationContext { /// /// If the is for a valid login. /// - public bool Valid { get; } + bool Valid { get; } + + /// + /// A that uniquely identifies the login session. + /// + string SessionId { get; } + + /// + /// When the login session expires. + /// + DateTimeOffset SessionExpiry { get; } /// /// The authenticated user. diff --git a/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs deleted file mode 100644 index cb70a50b18..0000000000 --- a/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Tgstation.Server.Host.Security -{ - /// - /// For creating and accessing authentication contexts. - /// - public interface IAuthenticationContextFactory - { - /// - /// Create an in the request pipeline for a given and . - /// - /// The of the . - /// The of the for the operation. - /// The the login must not be from before. - /// The for the operation. - /// A resulting in the created . - ValueTask CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset notBefore, CancellationToken cancellationToken); - } -} diff --git a/src/Tgstation.Server.Host/Security/ISessionInvalidationTracker.cs b/src/Tgstation.Server.Host/Security/ISessionInvalidationTracker.cs new file mode 100644 index 0000000000..ac4cec0b81 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/ISessionInvalidationTracker.cs @@ -0,0 +1,22 @@ +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Handles invalidating user sessions. + /// + public interface ISessionInvalidationTracker + { + /// + /// Invalidate all sessions for a given . + /// + /// The whose sessions should be invalidated. + public void UserModifiedInvalidateSessions(User user); + + /// + /// Track the session represented by a given . + /// + /// The representing the session to track. + public void TrackSession(IAuthenticationContext authenticationContext); + } +} diff --git a/src/Tgstation.Server.Host/Security/ITokenFactory.cs b/src/Tgstation.Server.Host/Security/ITokenFactory.cs index 707c5d78ff..777122a2ed 100644 --- a/src/Tgstation.Server.Host/Security/ITokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/ITokenFactory.cs @@ -26,7 +26,7 @@ namespace Tgstation.Server.Host.Security /// /// The to create the token for. Must have the field available. /// Whether or not this is an OAuth login. - /// A new . - TokenResponse CreateToken(Models.User user, bool oAuth); + /// A new token . + string CreateToken(Models.User user, bool oAuth); } } diff --git a/src/Tgstation.Server.Host/Security/ITokenValidator.cs b/src/Tgstation.Server.Host/Security/ITokenValidator.cs new file mode 100644 index 0000000000..c4fc4f2e4f --- /dev/null +++ b/src/Tgstation.Server.Host/Security/ITokenValidator.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Handles s. + /// + public interface ITokenValidator + { + /// + /// Handles . + /// + /// The . + /// The for the operation. + /// A representing the running operation. + Task ValidateToken(TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs b/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs new file mode 100644 index 0000000000..5f5157e361 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate.Subscriptions; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using Tgstation.Server.Host.GraphQL; +using Tgstation.Server.Host.GraphQL.Subscriptions; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Utils; + +namespace Tgstation.Server.Host.Security +{ + /// + sealed class SessionInvalidationTracker : ISessionInvalidationTracker + { + /// + /// The for the . + /// + readonly ITopicEventSender eventSender; + + /// + /// The for the . + /// + readonly IAsyncDelayer asyncDelayer; + + /// + /// The for the . + /// + readonly IHostApplicationLifetime applicationLifetime; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// of tracked s and s to the for their s. + /// + readonly ConcurrentDictionary<(string SessionId, long UserId), TaskCompletionSource> trackedSessions; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + /// The value of . + /// The value of . + public SessionInvalidationTracker( + ITopicEventSender eventSender, + IAsyncDelayer asyncDelayer, + IHostApplicationLifetime applicationLifetime, + ILogger logger) + { + this.eventSender = eventSender ?? throw new ArgumentNullException(nameof(eventSender)); + this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); + this.applicationLifetime = applicationLifetime ?? throw new ArgumentNullException(nameof(applicationLifetime)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + trackedSessions = new ConcurrentDictionary<(string, long), TaskCompletionSource>(); + } + + /// + public void TrackSession(IAuthenticationContext authenticationContext) + { + trackedSessions.GetOrAdd( + (authenticationContext.SessionId, authenticationContext.User.Require(x => x.Id)), + tuple => + { + var (localSessionId, localUserId) = tuple; + logger.LogTrace("Tracking session ID for user {userId}: {sessionId}", localUserId, localSessionId); + var tcs = new TaskCompletionSource(); + async void SendInvalidationTopic() + { + try + { + SessionInvalidationReason invalidationReason; + try + { + var otherCancellationReason = tcs.Task; + var timeTillSessionExpiry = authenticationContext.SessionExpiry - DateTimeOffset.UtcNow; + if (timeTillSessionExpiry > TimeSpan.Zero) + { + var delayTask = asyncDelayer.Delay(timeTillSessionExpiry, applicationLifetime.ApplicationStopping); + + await Task.WhenAny(delayTask, otherCancellationReason); + + if (delayTask.IsCompleted) + await delayTask; + } + + invalidationReason = otherCancellationReason.IsCompleted + ? await otherCancellationReason + : SessionInvalidationReason.TokenExpired; + + logger.LogTrace("Invalidating session ID {sessionID}: {reason}", localSessionId, invalidationReason); + } + catch (OperationCanceledException ex) + { + logger.LogTrace(ex, "Invalidating session ID {sessionID} due to server shutdown", localSessionId); + invalidationReason = SessionInvalidationReason.ServerShutdown; + } + + var topicName = Subscription.SessionInvalidatedTopic(authenticationContext); + await eventSender.SendAsync(topicName, invalidationReason, CancellationToken.None); // DCT: Session close messages should always be sent + await eventSender.CompleteAsync(topicName); + } + catch (Exception ex) + { + logger.LogError(ex, "Error tracking session {sessionId}!", localSessionId); + } + } + + SendInvalidationTopic(); + return tcs; + }); + } + + /// + public void UserModifiedInvalidateSessions(Models.User user) + { + ArgumentNullException.ThrowIfNull(user); + + var userId = user.Require(x => x.Id); + user.LastPasswordUpdate = DateTimeOffset.UtcNow; + + foreach (var key in trackedSessions + .Keys + .Where(key => key.UserId == userId) + .ToList()) + if (trackedSessions.TryRemove(key, out var tcs)) + tcs.TrySetResult(SessionInvalidationReason.UserUpdated); + } + } +} diff --git a/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs index 0dda24b472..d03c2f9e6d 100644 --- a/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs +++ b/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs @@ -1,13 +1,10 @@ using System; +using System.Collections.Generic; +using System.Linq; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Tgstation.Server.Api.Rights; -using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Security { @@ -16,8 +13,13 @@ namespace Tgstation.Server.Host.Security /// #pragma warning disable CA1019 [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] - sealed class TgsAuthorizeAttribute : AuthorizeAttribute, IAuthorizationFilter + sealed class TgsAuthorizeAttribute : AuthorizeAttribute { + /// + /// Role used to indicate access to the server is allowed. + /// + public const string UserEnabledRole = "Core.UserEnabled"; + /// /// Gets the associated with the if any. /// @@ -27,6 +29,7 @@ namespace Tgstation.Server.Host.Security /// Initializes a new instance of the class. /// public TgsAuthorizeAttribute() + : this(Enumerable.Empty()) { } @@ -35,8 +38,8 @@ namespace Tgstation.Server.Host.Security /// /// The required. public TgsAuthorizeAttribute(AdministrationRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.Administration; } @@ -45,8 +48,8 @@ namespace Tgstation.Server.Host.Security /// /// The required. public TgsAuthorizeAttribute(InstanceManagerRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.InstanceManager; } @@ -55,8 +58,8 @@ namespace Tgstation.Server.Host.Security /// /// The required. public TgsAuthorizeAttribute(RepositoryRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.Repository; } @@ -65,8 +68,8 @@ namespace Tgstation.Server.Host.Security /// /// The required. public TgsAuthorizeAttribute(EngineRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.Engine; } @@ -75,8 +78,8 @@ namespace Tgstation.Server.Host.Security /// /// The required. public TgsAuthorizeAttribute(DreamMakerRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.DreamMaker; } @@ -85,8 +88,8 @@ namespace Tgstation.Server.Host.Security /// /// The required. public TgsAuthorizeAttribute(DreamDaemonRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.DreamDaemon; } @@ -95,8 +98,8 @@ namespace Tgstation.Server.Host.Security /// /// The required. public TgsAuthorizeAttribute(ChatBotRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.ChatBots; } @@ -105,8 +108,8 @@ namespace Tgstation.Server.Host.Security /// /// The required. public TgsAuthorizeAttribute(ConfigurationRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.Configuration; } @@ -115,30 +118,20 @@ namespace Tgstation.Server.Host.Security /// /// The required. public TgsAuthorizeAttribute(InstancePermissionSetRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.InstancePermissionSet; } - /// - public void OnAuthorization(AuthorizationFilterContext context) + /// + /// Initializes a new instance of the class. + /// + /// An of roles to be required alongside the . + private TgsAuthorizeAttribute(IEnumerable roles) { - var services = context.HttpContext.RequestServices; - var authenticationContext = services.GetRequiredService(); - var logger = services.GetRequiredService>(); - - if (!authenticationContext.Valid) - { - logger.LogTrace("authenticationContext is invalid!"); - context.Result = new UnauthorizedResult(); - return; - } - - if (authenticationContext.User.Require(x => x.Enabled)) - return; - - logger.LogTrace("authenticationContext is for a disabled user!"); - context.Result = new ForbidResult(); + var listRoles = roles.ToList(); + listRoles.Add(UserEnabledRole); + Roles = String.Join(",", listRoles); } } } diff --git a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs new file mode 100644 index 0000000000..e03e7e6081 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using HotChocolate.Authorization; + +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Helper for using the with the system. + /// +#pragma warning disable CA1019 + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = true, Inherited = true)] + sealed class TgsGraphQLAuthorizeAttribute : AuthorizeAttribute + { + /// + /// Gets the associated with the if any. + /// + public RightsType? RightsType { get; } + + /// + /// Initializes a new instance of the class. + /// + public TgsGraphQLAuthorizeAttribute() + : this(Enumerable.Empty()) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(AdministrationRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) + { + RightsType = Api.Rights.RightsType.Administration; + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(InstanceManagerRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) + { + RightsType = Api.Rights.RightsType.InstanceManager; + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(RepositoryRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) + { + RightsType = Api.Rights.RightsType.Repository; + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(EngineRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) + { + RightsType = Api.Rights.RightsType.Engine; + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(DreamMakerRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) + { + RightsType = Api.Rights.RightsType.DreamMaker; + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(DreamDaemonRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) + { + RightsType = Api.Rights.RightsType.DreamDaemon; + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(ChatBotRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) + { + RightsType = Api.Rights.RightsType.ChatBots; + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(ConfigurationRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) + { + RightsType = Api.Rights.RightsType.Configuration; + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(InstancePermissionSetRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) + { + RightsType = Api.Rights.RightsType.InstancePermissionSet; + } + + /// + /// Initializes a new instance of the class. + /// + /// of role names. + private TgsGraphQLAuthorizeAttribute(IEnumerable roleNames) + { + var listRoles = roleNames.ToList(); + listRoles.Add(TgsAuthorizeAttribute.UserEnabledRole); + Roles = [.. listRoles]; + Apply = ApplyPolicy.Validation; + } + } +} diff --git a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs new file mode 100644 index 0000000000..495424073a --- /dev/null +++ b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs @@ -0,0 +1,41 @@ +using System; +using System.Reflection; + +using HotChocolate.Authorization; + +using Tgstation.Server.Host.Authority.Core; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Inherits the roles of s for GraphQL endpoints. + /// + /// The being wrapped. + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public sealed class TgsGraphQLAuthorizeAttribute : AuthorizeAttribute + where TAuthority : IAuthority + { + /// + /// The name of the method targeted. + /// + public string MethodName { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The method name to inherit roles from. + public TgsGraphQLAuthorizeAttribute(string methodName) + { + ArgumentNullException.ThrowIfNull(methodName); + + var authorityType = typeof(TAuthority); + var authorityMethod = authorityType.GetMethod(methodName) + ?? throw new InvalidOperationException($"Could not find method {methodName} on {authorityType}!"); + var authorizeAttribute = authorityMethod.GetCustomAttribute() + ?? throw new InvalidOperationException($"Could not find method {authorityType}.{methodName}() has no {nameof(TgsAuthorizeAttribute)}!"); + MethodName = methodName; + Roles = authorizeAttribute.Roles?.Split(',', StringSplitOptions.RemoveEmptyEntries); + Apply = ApplyPolicy.Validation; + } + } +} diff --git a/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs b/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs new file mode 100644 index 0000000000..6103eca33b --- /dev/null +++ b/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs @@ -0,0 +1,42 @@ +using System; +using System.Reflection; + +using Microsoft.AspNetCore.Authorization; + +using Tgstation.Server.Host.Authority.Core; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Inherits the roles of s for REST endpoints. + /// + /// The being wrapped. + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public sealed class TgsRestAuthorizeAttribute : AuthorizeAttribute + where TAuthority : IAuthority + { + /// + /// The name of the method targeted. + /// + public string MethodName { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The method name to inherit roles from. + public TgsRestAuthorizeAttribute(string methodName) + { + ArgumentNullException.ThrowIfNull(methodName); + + var authorityType = typeof(TAuthority); + var authorityMethod = authorityType.GetMethod(methodName) + ?? throw new InvalidOperationException($"Could not find method {methodName} on {authorityType}!"); + + var authorizeAttribute = authorityMethod.GetCustomAttribute() + ?? throw new InvalidOperationException($"Could not find method {authorityType}.{methodName}() has no {nameof(TgsAuthorizeAttribute)}!"); + + MethodName = methodName; + Roles = authorizeAttribute.Roles; + } + } +} diff --git a/src/Tgstation.Server.Host/Security/TokenFactory.cs b/src/Tgstation.Server.Host/Security/TokenFactory.cs index d2ab3c0527..9269472c87 100644 --- a/src/Tgstation.Server.Host/Security/TokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/TokenFactory.cs @@ -101,7 +101,7 @@ namespace Tgstation.Server.Host.Security } /// - public TokenResponse CreateToken(User user, bool oAuth) + public string CreateToken(User user, bool oAuth) { ArgumentNullException.ThrowIfNull(user); @@ -139,10 +139,7 @@ namespace Tgstation.Server.Host.Security expiry.UtcDateTime, now.UtcDateTime)); - var tokenResponse = new TokenResponse - { - Bearer = tokenHandler.WriteToken(securityToken), - }; + var tokenResponse = tokenHandler.WriteToken(securityToken); return tokenResponse; } diff --git a/src/Tgstation.Server.Host/Swarm/SwarmService.cs b/src/Tgstation.Server.Host/Swarm/SwarmService.cs index 9884ac7193..b6a0e51df6 100644 --- a/src/Tgstation.Server.Host/Swarm/SwarmService.cs +++ b/src/Tgstation.Server.Host/Swarm/SwarmService.cs @@ -211,7 +211,7 @@ namespace Tgstation.Server.Host.Swarm swarmServers = new List { - new SwarmServerInformation + new() { Address = swarmConfiguration.Address, PublicAddress = swarmConfiguration.PublicAddress, diff --git a/src/Tgstation.Server.Host/System/WindowsNetworkPromptReaper.cs b/src/Tgstation.Server.Host/System/WindowsNetworkPromptReaper.cs index 4892415571..9bbcd497ce 100644 --- a/src/Tgstation.Server.Host/System/WindowsNetworkPromptReaper.cs +++ b/src/Tgstation.Server.Host/System/WindowsNetworkPromptReaper.cs @@ -107,7 +107,7 @@ namespace Tgstation.Server.Host.System { if (registeredProcesses.Contains(process)) throw new InvalidOperationException("This process has already been registered for network prompt reaping!"); - logger.LogTrace("Registering process {0}...", process.Id); + logger.LogTrace("Registering process {pid}...", process.Id); registeredProcesses.Add(process); } @@ -148,7 +148,7 @@ namespace Tgstation.Server.Host.System continue; // not our bitch } - logger.LogTrace("Identified \"Network Accessibility\" window in owned process {0}", processId); + logger.LogTrace("Identified \"Network Accessibility\" window in owned process {pid}", processId); var found = false; foreach (var childHandle in GetAllChildHandles(window)) @@ -179,7 +179,7 @@ namespace Tgstation.Server.Host.System } if (!found) - logger.LogDebug("Unable to find \"Yes\" button for \"Network Accessibility\" window in owned process {0}!", processId); + logger.LogDebug("Unable to find \"Yes\" button for \"Network Accessibility\" window in owned process {pid}!", processId); } } catch (OperationCanceledException ex) diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index 49c85ec6e3..570b627d6e 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -57,6 +57,7 @@ <_Parameter4>$(TgsHostWatchdogVersion) <_Parameter5>$(TgsMariaDBRedistVersion) <_Parameter6>$(TgsSwarmProtocolVersion) + <_Parameter7>$(TgsGraphQLVersion) @@ -97,44 +98,49 @@ - + - + - + + + + + - + - + - + - + - + - + runtime; build; native; contentfiles; analyzers; buildtransitive - + - + + - + - + @@ -146,11 +152,11 @@ - + - + - + @@ -181,8 +187,6 @@ - - diff --git a/src/Tgstation.Server.Host/Utils/SwaggerConfiguration.cs b/src/Tgstation.Server.Host/Utils/SwaggerConfiguration.cs index dc15d9db68..d415e30ea3 100644 --- a/src/Tgstation.Server.Host/Utils/SwaggerConfiguration.cs +++ b/src/Tgstation.Server.Host/Utils/SwaggerConfiguration.cs @@ -270,7 +270,7 @@ namespace Tgstation.Server.Host.Utils .OrderBy(x => x.PutOnly) // Process PUTs last .ToList(); - if (requestOptions.Any() && requestOptions.All(x => x.Presence == FieldPresence.Ignored && !x.PutOnly)) + if (requestOptions.Count == 0 && requestOptions.All(x => x.Presence == FieldPresence.Ignored && !x.PutOnly)) subSchema.ReadOnly = true; var subSchemaId = tuple.Item2; @@ -370,7 +370,7 @@ namespace Tgstation.Server.Host.Utils operation.Security = new List { - new OpenApiSecurityRequirement + new() { { tokenScheme, @@ -450,7 +450,7 @@ namespace Tgstation.Server.Host.Utils operation.Security = new List { - new OpenApiSecurityRequirement + new() { { passwordScheme, diff --git a/src/Tgstation.Server.Host/appsettings.yml b/src/Tgstation.Server.Host/appsettings.yml index 353104e6de..b965a326ed 100644 --- a/src/Tgstation.Server.Host/appsettings.yml +++ b/src/Tgstation.Server.Host/appsettings.yml @@ -13,7 +13,7 @@ General: UserGroupLimit: 25 # Maximum number of allowed groups InstanceLimit: 10 # Maximum number of allowed instances ValidInstancePaths: # An array of directories instances may be created in (either directly or as a subdirectory). null removes the restriction - HostApiDocumentation: false # Make HTTP API documentation available at /api/doc/tgs_api.json + HostApiDocumentation: false # Make HTTP API documentation available at /api/doc/tgs_api.json and /api/graphql SkipAddingByondFirewallException: false # Windows Only: Prevent running netsh.exe to add a firewall exception for installed engine binaries DeploymentDirectoryCopyTasksPerCore: 100 # Maximum number of concurrent file copy operations PER available CPU core OpenDreamGitUrl: https://github.com/OpenDreamProject/OpenDream # The repository to retrieve OpenDream from @@ -40,7 +40,7 @@ Logging: Microsoft: Warning # Level of stdout verbosity from Microsoft dependecies (i.e. webserver, database object relational mapper, etc...). Can be one of Trace, Debug, Information, Warning, Error, or Critical ControlPanel: Enable: false # If the web control panel is hosted alongside the server - Channel: https://tgstation.github.io/tgstation-server-webpanel/api/${Major}.${Minor}.${Patch} # Channel for live web control panel updates. The tokens ${Major}, ${Minor}, and ${Patch} are substituted for the version of the HTTP API TGS was built with + Channel: https://tgstation.github.io/tgstation-server-webpanel/api/${Major}.${Minor}.${Patch} # Channel for live web control panel updates. The tokens ${Major}, ${Minor}, and ${Patch} are substituted for the version of the REST API TGS was built with AllowAnyOrigin: false # Enable the `Access-Control-Allow-Origin: *` header for HTTP responses AllowedOrigins: [] # Explict list of origins for `Access-Control-Allow-Origin:` HTTP header. Ignored if AllowAnyOrigin is true PublicPath: # Used if TGS is not hosted at the website root. If TGS is hosted under `https://domain.com/tgs`, this should be `/tgs` diff --git a/src/Tgstation.Server.Shared/Tgstation.Server.Shared.csproj b/src/Tgstation.Server.Shared/Tgstation.Server.Shared.csproj index ebea982264..14c6e89981 100644 --- a/src/Tgstation.Server.Shared/Tgstation.Server.Shared.csproj +++ b/src/Tgstation.Server.Shared/Tgstation.Server.Shared.csproj @@ -10,11 +10,11 @@ - + - + diff --git a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestDiscordProvider.cs b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestDiscordProvider.cs index 58c63c6a99..6260d3c3b0 100644 --- a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestDiscordProvider.cs +++ b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestDiscordProvider.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -64,9 +65,9 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests Assert.ThrowsException(() => new DiscordProvider(mockJobManager, mockDel, mockLogger, null, null, null)); var mockAss = Mock.Of(); Assert.ThrowsException(() => new DiscordProvider(mockJobManager, mockDel, mockLogger, mockAss, null, null)); - Assert.ThrowsException(() => new DiscordProvider(mockJobManager, mockDel, mockLogger, mockAss, bot, null)); - var mockGen = new GeneralConfiguration(); - await new DiscordProvider(mockJobManager, mockDel, mockLogger, mockAss, bot, mockGen).DisposeAsync(); + var mockGen = Mock.Of>(); + Assert.ThrowsException(() => new DiscordProvider(mockJobManager, mockDel, mockLogger, mockAss, mockGen, null)); + await new DiscordProvider(mockJobManager, mockDel, mockLogger, mockAss, mockGen, bot).DisposeAsync(); } static ValueTask InvokeConnect(IProvider provider, CancellationToken cancellationToken = default) => (ValueTask)provider.GetType().GetMethod("Connect", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(provider, new object[] { cancellationToken }); @@ -75,12 +76,12 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests public async Task TestConnectWithFakeTokenFails() { var mockLogger = new Mock>(); - await using var provider = new DiscordProvider(mockJobManager, Mock.Of(), mockLogger.Object, Mock.Of(), new ChatBot + await using var provider = new DiscordProvider(mockJobManager, Mock.Of(), mockLogger.Object, Mock.Of(), Mock.Of>(), new ChatBot { ReconnectionInterval = 1, ConnectionString = "asdf", Instance = new Models.Instance(), - }, new GeneralConfiguration()); + }); await Assert.ThrowsExceptionAsync(async () => await InvokeConnect(provider)); Assert.IsFalse(provider.Connected); } @@ -95,7 +96,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests Assert.Fail("TGS_TEST_DISCORD_TOKEN is not a valid Discord connection string!"); var mockLogger = new Mock>(); - await using var provider = new DiscordProvider(mockJobManager, Mock.Of(), mockLogger.Object, Mock.Of(), testToken1, new GeneralConfiguration()); + await using var provider = new DiscordProvider(mockJobManager, Mock.Of(), mockLogger.Object, Mock.Of(), Mock.Of>(), testToken1); Assert.IsFalse(provider.Connected); await InvokeConnect(provider); Assert.IsTrue(provider.Connected); diff --git a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs index 3f1e050219..9bb8c75fb2 100644 --- a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs +++ b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs @@ -4,11 +4,13 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.System; @@ -22,24 +24,28 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests [TestMethod] public async Task TestConstructionAndDisposal() { - Assert.ThrowsException(() => new IrcProvider(null, null, null, null, null)); + Assert.ThrowsException(() => new IrcProvider(null, null, null, null, null, null)); var mockJobManager = new Mock(); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, null, null, null, null)); + Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, null, null, null, null, null)); var mockAsyncDelayer = new Mock(); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, null, null, null)); + Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, null, null, null, null)); var mockLogger = new Mock>(); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, null, null)); - var mockAss = new Mock(); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, null)); - + Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, null, null, null)); var mockBot = new ChatBot { Name = "test", Instance = new Models.Instance(), Provider = ChatProvider.Irc }; + Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockBot, null, null)); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot)); + var mockAss = new Mock(); + + Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockBot, mockAss.Object, null)); + + var mockLogConf = new Mock>(); + mockLogConf.SetupGet(x => x.CurrentValue).Returns(new FileLoggingConfiguration()); + Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockBot, mockAss.Object, mockLogConf.Object)); mockBot.ConnectionString = new IrcConnectionStringBuilder { @@ -49,7 +55,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests Port = 6667 }.ToString(); - await new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot).DisposeAsync(); + await new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockBot, mockAss.Object, mockLogConf.Object).DisposeAsync(); } static ValueTask InvokeConnect(IProvider provider, CancellationToken cancellationToken = default) => (ValueTask)provider.GetType().GetMethod("Connect", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(provider, new object[] { cancellationToken }); @@ -78,16 +84,32 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests .Setup(x => x.WaitForJobCompletion(It.IsNotNull(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(ValueTask.FromResult(true)); var mockJobManager = mockSetup.Object; - await using var provider = new IrcProvider(mockJobManager, new AsyncDelayer(), loggerFactory.CreateLogger(), Mock.Of(), new ChatBot + + var chatBot = new ChatBot { ConnectionString = actualToken, Provider = ChatProvider.Irc, Instance = new Models.Instance(), - }); + }; + + var mockLogConf = new Mock>(); + mockLogConf.SetupGet(x => x.CurrentValue).Returns(new FileLoggingConfiguration()); + await using var provider = new IrcProvider(mockJobManager, new AsyncDelayer(), loggerFactory.CreateLogger(), chatBot, Mock.Of(), mockLogConf.Object); Assert.IsFalse(provider.Connected); await InvokeConnect(provider); Assert.IsTrue(provider.Connected); + await Task.Delay(2000); // IRC servers do not like it when you connect and disconnect in rapid succession + + await provider.Disconnect(default); + Assert.IsFalse(provider.Connected); + + await Task.Delay(2000); // same as above + + await InvokeConnect(provider); + await Task.Delay(2000); // make sure it stays connected after a reconnect attempt + Assert.IsTrue(provider.Connected); + await provider.Disconnect(default); Assert.IsFalse(provider.Connected); } diff --git a/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs b/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs index 1e0d7b7fa8..3ffee42382 100644 --- a/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs +++ b/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs @@ -13,32 +13,6 @@ namespace Tgstation.Server.Host.Security.Tests [TestClass] public sealed class TestAuthenticationContext { - [TestMethod] - public void TestConstruction() - { - Assert.ThrowsException(() => new AuthenticationContext().Initialize(null, null, null)); - var mockSystemIdentity = new Mock(); - - var user = new User() - { - PermissionSet = new PermissionSet() - }; - - var authContext = new AuthenticationContext(); - Assert.ThrowsException(() => new AuthenticationContext().Initialize(mockSystemIdentity.Object, null, null)); - - var instanceUser = new InstancePermissionSet(); - - Assert.ThrowsException(() => new AuthenticationContext().Initialize(null, null, instanceUser)); - Assert.ThrowsException(() => new AuthenticationContext().Initialize(mockSystemIdentity.Object, null, instanceUser)); - new AuthenticationContext().Initialize(mockSystemIdentity.Object, user, null); - new AuthenticationContext().Initialize(null, user, instanceUser); - new AuthenticationContext().Initialize(mockSystemIdentity.Object, user, instanceUser); - user.SystemIdentifier = "root"; - Assert.ThrowsException(() => new AuthenticationContext().Initialize(null, user, null)); - } - - [TestMethod] public void TestGetRightsGeneric() { @@ -48,7 +22,7 @@ namespace Tgstation.Server.Host.Security.Tests }; var instanceUser = new InstancePermissionSet(); var authContext = new AuthenticationContext(); - authContext.Initialize(null, user, instanceUser); + authContext.Initialize(user, DateTimeOffset.UtcNow, "asdf", instanceUser, null); user.PermissionSet.AdministrationRights = AdministrationRights.WriteUsers; instanceUser.EngineRights = EngineRights.InstallOfficialOrChangeActiveByondVersion | EngineRights.ReadActive; diff --git a/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs b/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs index 2fbc888b1b..c5f22614b8 100644 --- a/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs +++ b/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs @@ -89,7 +89,7 @@ namespace Tgstation.Server.Host.Swarm.Tests public TokenValidationParameters ValidationParameters => throw new NotSupportedException(); - public TokenResponse CreateToken(User user, bool oAuth) + public string CreateToken(User user, bool oAuth) { throw new NotSupportedException(); } diff --git a/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj b/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj index 261c136fc7..a0e5d2ba45 100644 --- a/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj +++ b/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj @@ -7,7 +7,7 @@ - + diff --git a/tests/Tgstation.Server.Tests/Live/ApiAssert.cs b/tests/Tgstation.Server.Tests/Live/ApiAssert.cs index 1fe6742015..5bd736c6cc 100644 --- a/tests/Tgstation.Server.Tests/Live/ApiAssert.cs +++ b/tests/Tgstation.Server.Tests/Live/ApiAssert.cs @@ -1,9 +1,15 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; -using Tgstation.Server.Api.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using StrawberryShake; + using Tgstation.Server.Client; +using Tgstation.Server.Client.GraphQL; namespace Tgstation.Server.Tests.Live { @@ -19,7 +25,7 @@ namespace Tgstation.Server.Tests.Live /// A resulting in a . /// The expected . /// A representing the running operation, - public static async ValueTask ThrowsException(Func action, ErrorCode? expectedErrorCode = null) + public static async ValueTask ThrowsException(Func action, Api.Models.ErrorCode? expectedErrorCode = null) where TApiException : ApiException { try @@ -43,7 +49,7 @@ namespace Tgstation.Server.Tests.Live /// A resulting in a . /// The expected . /// A representing the running operation, - public static async ValueTask ThrowsException(Func> action, ErrorCode? expectedErrorCode = null) + public static async ValueTask ThrowsException(Func> action, Api.Models.ErrorCode? expectedErrorCode = null) where TApiException : ApiException { try @@ -58,5 +64,25 @@ namespace Tgstation.Server.Tests.Live Assert.Fail($"Expected exception {typeof(TApiException)}!"); } + + public static async ValueTask OperationFails( + IGraphQLServerClient client, + Func>> operationInvoker, + Func payloadSelector, + Client.GraphQL.ErrorCode expectedErrorCode, + CancellationToken cancellationToken) + where TResultData : class + { + var operationResult = await client.RunOperation(operationInvoker, cancellationToken); + operationResult.EnsureNoErrors(); + + var payload = payloadSelector(operationResult.Data); + + var payloadErrors = (IEnumerable)payload.GetType().GetProperty("Errors").GetValue(payload); + var error = payloadErrors.Single(); + + var errorCode = (ErrorCode)error.GetType().GetProperty("ErrorCode").GetValue(error); + Assert.AreEqual(expectedErrorCode, errorCode); + } } } diff --git a/tests/Tgstation.Server.Tests/Live/GraphQLServerClientExtensions.cs b/tests/Tgstation.Server.Tests/Live/GraphQLServerClientExtensions.cs new file mode 100644 index 0000000000..993b8999ad --- /dev/null +++ b/tests/Tgstation.Server.Tests/Live/GraphQLServerClientExtensions.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using StrawberryShake; + +using Tgstation.Server.Client.GraphQL; + +namespace Tgstation.Server.Tests.Live +{ + static class GraphQLServerClientExtensions + { + public static async ValueTask RunQueryEnsureNoErrors( + this IGraphQLServerClient serverClient, + Func>> operationExecutor, + CancellationToken cancellationToken) + where TResultData : class + { + var result = await serverClient.RunOperation(operationExecutor, cancellationToken); + result.EnsureNoErrors(); + return result.Data; + } + + public static async ValueTask RunMutationEnsureNoErrors( + this IGraphQLServerClient serverClient, + Func>> operationExecutor, + Func payloadSelector, + CancellationToken cancellationToken) + where TResultData : class + { + var result = await serverClient.RunOperation(operationExecutor, cancellationToken); + result.EnsureNoErrors(); + var data = payloadSelector(result.Data); + var errorsObject = data.GetType().GetProperty("Errors").GetValue(data); + if (errorsObject != null) + { + var errorsCount = (int)errorsObject.GetType().GetProperty("Count").GetValue(errorsObject); + + Assert.AreEqual(0, errorsCount); + } + + return data; + } + } +} diff --git a/tests/Tgstation.Server.Tests/Live/HoldLastObserver.cs b/tests/Tgstation.Server.Tests/Live/HoldLastObserver.cs new file mode 100644 index 0000000000..267f9e9012 --- /dev/null +++ b/tests/Tgstation.Server.Tests/Live/HoldLastObserver.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading; + +namespace Tgstation.Server.Tests.Live +{ + sealed class HoldLastObserver : IObserver + { + public bool Completed { get; private set; } + + public Exception LastError { get; private set; } + + public T LastValue { get; private set; } + + public ulong ErrorCount => errorCount; + + public ulong ResultCount => resultCount; + + ulong errorCount; + ulong resultCount; + + public void OnCompleted() + { + Completed = true; + } + + public void OnError(Exception error) + { + Interlocked.Increment(ref errorCount); + LastError = error; + } + + public void OnNext(T value) + { + Interlocked.Increment(ref resultCount); + LastValue = value; + } + } +} diff --git a/tests/Tgstation.Server.Tests/Live/IMultiServerClient.cs b/tests/Tgstation.Server.Tests/Live/IMultiServerClient.cs new file mode 100644 index 0000000000..315052e1a2 --- /dev/null +++ b/tests/Tgstation.Server.Tests/Live/IMultiServerClient.cs @@ -0,0 +1,34 @@ +using StrawberryShake; +using System.Threading.Tasks; +using System.Threading; +using System; +using Tgstation.Server.Client.GraphQL; +using Tgstation.Server.Client; + +namespace Tgstation.Server.Tests.Live +{ + interface IMultiServerClient + { + ValueTask Execute( + Func restAction, + Func graphQLAction); + + ValueTask<(TRestResult, TGraphQLResult)> ExecuteReadOnlyConfirmEquivalence( + Func> restAction, + Func>> graphQLAction, + Func comparison, + CancellationToken cancellationToken) + where TGraphQLResult : class; + + /// + /// Subcribes to the GraphQL subscription indicated by . + /// + /// The of the 's . + /// A which initiates a single subscription on a given and returns a resulting in the . + /// The for s. + /// The for the operation. + /// A resulting in the representing the lifetime of the subscription. + ValueTask Subscribe(Func>> operationExecutor, IObserver> observer, CancellationToken cancellationToken) + where TResultData : class; + } +} diff --git a/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs index dc8c71753d..f2bb84c639 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs @@ -34,10 +34,10 @@ namespace Tgstation.Server.Tests.Live.Instance long? permlessPsId; - public JobsHubTests(IRestServerClient permedUser, IRestServerClient permlessUser) + public JobsHubTests(MultiServerClient permedUser, MultiServerClient permlessUser) { - this.permedUser = permedUser; - this.permlessUser = permlessUser; + this.permedUser = permedUser.RestClient; + this.permlessUser = permlessUser.RestClient; Assert.AreNotSame(permedUser, permlessUser); diff --git a/tests/Tgstation.Server.Tests/Live/LiveTestingServer.cs b/tests/Tgstation.Server.Tests/Live/LiveTestingServer.cs index f9ed0b926a..bbd6bb176c 100644 --- a/tests/Tgstation.Server.Tests/Live/LiveTestingServer.cs +++ b/tests/Tgstation.Server.Tests/Live/LiveTestingServer.cs @@ -158,6 +158,9 @@ namespace Tgstation.Server.Tests.Live "Telemetry:DisableVersionReporting=true", }; + if (MultiServerClient.UseGraphQL) + args.Add("Internal:EnableGraphQL=true"); + swarmArgs = new List(); if (swarmConfiguration != null) { diff --git a/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs index 801f4451bd..9cc8f280b8 100644 --- a/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs +++ b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs @@ -1,48 +1,70 @@ using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; +using StrawberryShake; + using Tgstation.Server.Client; using Tgstation.Server.Client.GraphQL; +using Tgstation.Server.Common.Extensions; namespace Tgstation.Server.Tests.Live { - sealed class MultiServerClient + sealed class MultiServerClient : IMultiServerClient, IAsyncDisposable { - readonly IRestServerClient restServerClient; - readonly IGraphQLServerClient graphQLServerClient; - - readonly bool useGraphQL; + public IRestServerClient RestClient { get; } + public IGraphQLServerClient GraphQLClient { get; } public MultiServerClient(IRestServerClient restServerClient, IGraphQLServerClient graphQLServerClient) { - this.restServerClient = restServerClient ?? throw new ArgumentNullException(nameof(restServerClient)); - this.graphQLServerClient = graphQLServerClient ?? throw new ArgumentNullException(nameof(graphQLServerClient)); - this.useGraphQL = Boolean.TryParse(Environment.GetEnvironmentVariable("TGS_TEST_GRAPHQL"), out var result) && result; + this.RestClient = restServerClient; + this.GraphQLClient = graphQLServerClient; } + public static bool UseGraphQL => Boolean.TryParse(Environment.GetEnvironmentVariable("TGS_TEST_GRAPHQL"), out var result) && result; + + public ValueTask DisposeAsync() + => ValueTaskExtensions.WhenAll( + RestClient.DisposeAsync(), + GraphQLClient?.DisposeAsync() ?? ValueTask.CompletedTask); + public ValueTask Execute( Func restAction, - Func graphQLAction) + Func graphQLAction) { - if (useGraphQL) - return graphQLServerClient.RunQuery(graphQLAction); + if (UseGraphQL) + return graphQLAction(GraphQLClient); - return restAction(restServerClient); + return restAction(RestClient); } - public async ValueTask ExecuteReadOnlyConfirmEquivalence( + public async ValueTask<(TRestResult, TGraphQLResult)> ExecuteReadOnlyConfirmEquivalence( Func> restAction, - Func> graphQLAction, - Func comparison) + Func>> graphQLAction, + Func comparison, + CancellationToken cancellationToken) + where TGraphQLResult : class { - var restTask = restAction(this.restServerClient); - TGraphQLResult graphQLResult = default; - await this.graphQLServerClient.RunQuery(async gqlClient => graphQLResult = await graphQLAction(gqlClient)); + var restTask = restAction(RestClient); + if (!UseGraphQL) + { + return (await restTask, null); + } + + var graphQLResult = await GraphQLClient.RunOperation(graphQLAction, cancellationToken); + + graphQLResult.EnsureNoErrors(); var restResult = await restTask; - Assert.IsTrue(comparison(restResult, graphQLResult), "REST/GraphQL results differ!"); + var comparisonResult = comparison(restResult, graphQLResult.Data); + Assert.IsTrue(comparisonResult, "REST/GraphQL results differ!"); + + return (restResult, graphQLResult.Data); } + + public ValueTask Subscribe(Func>> operationExecutor, IObserver> observer, CancellationToken cancellationToken) where TResultData : class + => GraphQLClient?.Subscribe(operationExecutor, observer, cancellationToken) ?? ValueTask.FromResult(new CancellationTokenSource()); } } diff --git a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs index 5083ff6956..f04ce92007 100644 --- a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs +++ b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs @@ -26,6 +26,9 @@ using Tgstation.Server.Client; using Tgstation.Server.Client.Extensions; using Tgstation.Server.Common.Extensions; using Tgstation.Server.Host; +using Tgstation.Server.Client.GraphQL; +using System.Linq; +using StrawberryShake; namespace Tgstation.Server.Tests.Live { @@ -63,7 +66,7 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.BadHeaders, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString() + Routes.ApiRoot.TrimStart('/'))) @@ -90,7 +93,7 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.ApiMismatch, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.ApiMismatch, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Get, string.Concat(url.ToString(), Routes.Administration.AsSpan(1)))) @@ -104,7 +107,7 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.ApiMismatch, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.ApiMismatch, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Post, string.Concat(url.ToString(), Routes.Administration.AsSpan(1)))) @@ -122,7 +125,7 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.ModelValidationFailure, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.ModelValidationFailure, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Post, string.Concat(url.ToString(), Routes.DreamDaemon.AsSpan(1)))) @@ -146,7 +149,7 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.InstanceHeaderRequired, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.InstanceHeaderRequired, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString() + Routes.ApiRoot.TrimStart('/'))) @@ -160,7 +163,7 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.BadHeaders, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString() + Routes.ApiRoot.TrimStart('/'))) @@ -175,7 +178,7 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.BadHeaders, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString() + Routes.ApiRoot.TrimStart('/'))) @@ -192,7 +195,7 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.BadHeaders, message.ErrorCode); } } @@ -228,7 +231,7 @@ namespace Tgstation.Server.Tests.Live using var httpClient = new HttpClient(); // just hitting each type of oauth provider for coverage - foreach (var I in Enum.GetValues(typeof(OAuthProvider))) + foreach (var I in Enum.GetValues(typeof(Api.Models.OAuthProvider))) using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString() + Routes.ApiRoot.TrimStart('/'))) { request.Headers.Accept.Clear(); @@ -261,7 +264,7 @@ namespace Tgstation.Server.Tests.Live var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); Assert.AreEqual(MediaTypeNames.Application.Json, response.Content.Headers.ContentType.MediaType); - Assert.AreEqual(ErrorCode.ModelValidationFailure, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.ModelValidationFailure, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Put, string.Concat(url.ToString(), Routes.Transfer.AsSpan(1)))) @@ -276,7 +279,7 @@ namespace Tgstation.Server.Tests.Live var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = JsonConvert.DeserializeObject(content); Assert.AreEqual(MediaTypeNames.Application.Json, response.Content.Headers.ContentType.MediaType); - Assert.AreEqual(ErrorCode.ModelValidationFailure, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.ModelValidationFailure, message.ErrorCode); } using (var request = new HttpRequestMessage(HttpMethod.Get, string.Concat(url.ToString(), Routes.Transfer.AsSpan(1), "?ticket=veryfaketicket"))) @@ -444,12 +447,7 @@ namespace Tgstation.Server.Tests.Live Assert.AreNotEqual(HubConnectionState.Connected, testUserConn1.State); - await using var testUserConn2 = (HubConnection)await testUserClient.SubscribeToJobUpdates(proxy, cancellationToken: cancellationToken); - - for (var i = 0; i < 10 && testUserConn2.State == HubConnectionState.Connected; ++i) - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - - Assert.AreNotEqual(HubConnectionState.Connected, testUserConn2.State); + await ApiAssert.ThrowsException(async () => await testUserClient.SubscribeToJobUpdates(proxy, cancellationToken: cancellationToken)); } finally { @@ -457,9 +455,30 @@ namespace Tgstation.Server.Tests.Live } } + static async Task TestGraphQLLogin(IRestServerClientFactory clientFactory, IRestServerClient restClient, CancellationToken cancellationToken) + { + if (!MultiServerClient.UseGraphQL) + return; + + await using var gqlClient = new GraphQLServerClientFactory(clientFactory).CreateUnauthenticated(restClient.Url); + var result = await gqlClient.RunOperation(client => client.Login.ExecuteAsync(cancellationToken), cancellationToken); + + Assert.IsNotNull(result.Data); + Assert.IsNull(result.Data.Login.LoginResult); + Assert.IsNotNull(result.Data.Login.Errors); + Assert.AreEqual(1, result.Data.Login.Errors.Count); + var castResult = result.Data.Login.Errors[0] is ILogin_Login_Errors_ErrorMessageError loginError; + Assert.IsTrue(castResult); + loginError = (ILogin_Login_Errors_ErrorMessageError)result.Data.Login.Errors[0]; + Assert.AreEqual(Client.GraphQL.ErrorCode.BadHeaders, loginError.ErrorCode.Value); + Assert.IsNotNull(loginError.Message); + Assert.IsNotNull(loginError.AdditionalData); + } + public static Task Run(IRestServerClientFactory clientFactory, IRestServerClient serverClient, CancellationToken cancellationToken) => Task.WhenAll( TestRequestValidation(serverClient, cancellationToken), + TestGraphQLLogin(clientFactory, serverClient, cancellationToken), TestOAuthFails(serverClient, cancellationToken), TestServerInformation(clientFactory, serverClient, cancellationToken), TestInvalidTransfers(serverClient, cancellationToken), diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 75f7c88875..9140a29df3 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -27,6 +27,8 @@ using Newtonsoft.Json; using Npgsql; +using StrawberryShake; + using Tgstation.Server.Api; using Tgstation.Server.Api.Extensions; using Tgstation.Server.Api.Models; @@ -72,7 +74,14 @@ namespace Tgstation.Server.Tests.Live _ = mainDMPort.Value; } - readonly RestServerClientFactory clientFactory = new (new ProductHeaderValue(Assembly.GetExecutingAssembly().GetName().Name, Assembly.GetExecutingAssembly().GetName().Version.ToString())); + readonly RestServerClientFactory restClientFactory; + readonly GraphQLServerClientFactory graphQLClientFactory; + + public TestLiveServer() + { + restClientFactory = new(new ProductHeaderValue(Assembly.GetExecutingAssembly().GetName().Name, Assembly.GetExecutingAssembly().GetName().Version.ToString())); + graphQLClientFactory = new GraphQLServerClientFactory(restClientFactory); + } public static List GetEngineServerProcessesOnPort(EngineType engineType, ushort? port) { @@ -321,18 +330,18 @@ namespace Tgstation.Server.Tests.Live request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version); - request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.OAuthAuthenticationScheme, adminClient.Token.Bearer); + request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.OAuthAuthenticationScheme, adminClient.RestClient.Token.Bearer); request.Headers.Add(ApiHeaders.OAuthProviderHeader, Api.Models.OAuthProvider.GitHub.ToString()); using var response = await httpClient.SendAsync(request, cancellationToken); Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(); var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.OAuthProviderDisabled, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.OAuthProviderDisabled, message.ErrorCode); } //attempt to update to stable var responseModel = await TestWithoutAndWithPermission( - () => adminClient.Administration.Update( + () => adminClient.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion, @@ -340,7 +349,7 @@ namespace Tgstation.Server.Tests.Live }, null, cancellationToken), - adminClient, + adminClient.RestClient, AdministrationRights.ChangeVersion); Assert.IsNotNull(responseModel); @@ -349,7 +358,7 @@ namespace Tgstation.Server.Tests.Live try { - var serverInfoTask = adminClient.ServerInformation(cancellationToken).AsTask(); + var serverInfoTask = adminClient.RestClient.ServerInformation(cancellationToken).AsTask(); var completedTask = await Task.WhenAny(serverTask, serverInfoTask); if (completedTask == serverInfoTask) { @@ -402,7 +411,7 @@ namespace Tgstation.Server.Tests.Live var downloadStream = await download.GetResult(cancellationToken); var responseModel = await TestWithoutAndWithPermission( - () => adminClient.Administration.Update( + () => adminClient.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion, @@ -410,7 +419,7 @@ namespace Tgstation.Server.Tests.Live }, downloadStream, cancellationToken), - adminClient, + adminClient.RestClient, AdministrationRights.UploadVersion); Assert.IsNotNull(responseModel); @@ -450,14 +459,14 @@ namespace Tgstation.Server.Tests.Live var testUpdateVersion = new Version(5, 11, 20); await using var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken); await ApiAssert.ThrowsException( - () => adminClient.Administration.Update( + () => adminClient.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = testUpdateVersion }, null, cancellationToken), - ErrorCode.ResourceNotPresent); + Api.Models.ErrorCode.ResourceNotPresent); } finally { @@ -500,7 +509,7 @@ namespace Tgstation.Server.Tests.Live { await using var controllerClient = await CreateAdminClient(controller.ApiUrl, cancellationToken); - var controllerInfo = await controllerClient.ServerInformation(cancellationToken); + var controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken); static void CheckInfo(ServerInformationResponse serverInformation) { @@ -515,7 +524,7 @@ namespace Tgstation.Server.Tests.Live CheckInfo(controllerInfo); // test update - var responseModel = await controllerClient.Administration.Update( + var responseModel = await controllerClient.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion @@ -603,7 +612,7 @@ namespace Tgstation.Server.Tests.Live await using var node1Client = await CreateAdminClient(node1.ApiUrl, cancellationToken); await using var node2Client = await CreateAdminClient(node2.ApiUrl, cancellationToken); - var controllerInfo = await controllerClient.ServerInformation(cancellationToken); + var controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken); async Task WaitForSwarmServerUpdate() { @@ -611,7 +620,7 @@ namespace Tgstation.Server.Tests.Live do { await Task.Delay(TimeSpan.FromSeconds(10)); - serverInformation = await node1Client.ServerInformation(cancellationToken); + serverInformation = await node1Client.RestClient.ServerInformation(cancellationToken); } while (serverInformation.SwarmServers.Count == 1); } @@ -644,13 +653,13 @@ namespace Tgstation.Server.Tests.Live WaitForSwarmServerUpdate(), Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); - var node2Info = await node2Client.ServerInformation(cancellationToken); - var node1Info = await node1Client.ServerInformation(cancellationToken); + var node2Info = await node2Client.RestClient.ServerInformation(cancellationToken); + var node1Info = await node1Client.RestClient.ServerInformation(cancellationToken); CheckInfo(node1Info); CheckInfo(node2Info); // check user info is shared - var newUser = await node2Client.Users.Create(new UserCreateRequest + var newUser = await node2Client.RestClient.Users.Create(new UserCreateRequest { Name = "asdf", Password = "asdfasdfasdfasdf", @@ -661,20 +670,20 @@ namespace Tgstation.Server.Tests.Live } }, cancellationToken); - var node1User = await node1Client.Users.GetId(newUser, cancellationToken); + var node1User = await node1Client.RestClient.Users.GetId(newUser, cancellationToken); Assert.AreEqual(newUser.Name, node1User.Name); Assert.AreEqual(newUser.Enabled, node1User.Enabled); - await using var controllerUserClient = await clientFactory.CreateFromLogin( + await using var controllerUserClient = await restClientFactory.CreateFromLogin( controllerAddress, newUser.Name, "asdfasdfasdfasdf"); - await using var node1TokenCopiedClient = clientFactory.CreateFromToken(node1.RootUrl, controllerUserClient.Token); + await using var node1TokenCopiedClient = restClientFactory.CreateFromToken(node1.RootUrl, controllerUserClient.Token); await node1TokenCopiedClient.Administration.Read(false, cancellationToken); // check instance info is not shared - var controllerInstance = await controllerClient.Instances.CreateOrAttach( + var controllerInstance = await controllerClient.RestClient.Instances.CreateOrAttach( new InstanceCreateRequest { Name = "ControllerInstance", @@ -682,27 +691,27 @@ namespace Tgstation.Server.Tests.Live }, cancellationToken); - var node2Instance = await node2Client.Instances.CreateOrAttach( + var node2Instance = await node2Client.RestClient.Instances.CreateOrAttach( new InstanceCreateRequest { Name = "Node2Instance", Path = Path.Combine(node2.Directory, "Node2Instance") }, cancellationToken); - var node2InstanceList = await node2Client.Instances.List(null, cancellationToken); + var node2InstanceList = await node2Client.RestClient.Instances.List(null, cancellationToken); Assert.AreEqual(1, node2InstanceList.Count); Assert.AreEqual(node2Instance.Id, node2InstanceList[0].Id); - Assert.IsNotNull(await node2Client.Instances.GetId(node2Instance, cancellationToken)); - var controllerInstanceList = await controllerClient.Instances.List(null, cancellationToken); + Assert.IsNotNull(await node2Client.RestClient.Instances.GetId(node2Instance, cancellationToken)); + var controllerInstanceList = await controllerClient.RestClient.Instances.List(null, cancellationToken); Assert.AreEqual(1, controllerInstanceList.Count); Assert.AreEqual(controllerInstance.Id, controllerInstanceList[0].Id); - Assert.IsNotNull(await controllerClient.Instances.GetId(controllerInstance, cancellationToken)); + Assert.IsNotNull(await controllerClient.RestClient.Instances.GetId(controllerInstance, cancellationToken)); - await ApiAssert.ThrowsException(() => controllerClient.Instances.GetId(node2Instance, cancellationToken), ErrorCode.ResourceNotPresent); - await ApiAssert.ThrowsException(() => node1Client.Instances.GetId(controllerInstance, cancellationToken), ErrorCode.ResourceNotPresent); + await ApiAssert.ThrowsException(() => controllerClient.RestClient.Instances.GetId(node2Instance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent); + await ApiAssert.ThrowsException(() => node1Client.RestClient.Instances.GetId(controllerInstance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent); // test update - await node1Client.Administration.Update( + await node1Client.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion @@ -743,13 +752,13 @@ namespace Tgstation.Server.Tests.Live await using var controllerClient2 = await CreateAdminClient(controller.ApiUrl, cancellationToken); await using var node1Client2 = await CreateAdminClient(node1.ApiUrl, cancellationToken); - await ApiAssert.ThrowsException(() => controllerClient2.Administration.Update( + await ApiAssert.ThrowsException(() => controllerClient2.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion }, null, - cancellationToken), ErrorCode.SwarmIntegrityCheckFailed); + cancellationToken), Api.Models.ErrorCode.SwarmIntegrityCheckFailed); // regression: test updating also works from the controller serverTask = Task.WhenAll( @@ -764,7 +773,7 @@ namespace Tgstation.Server.Tests.Live do { await Task.Delay(TimeSpan.FromSeconds(10)); - serverInformation = await node2Client2.ServerInformation(cancellationToken); + serverInformation = await node2Client2.RestClient.ServerInformation(cancellationToken); } while (serverInformation.SwarmServers.Count == 1); } @@ -773,8 +782,8 @@ namespace Tgstation.Server.Tests.Live WaitForSwarmServerUpdate2(), Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); - var node2Info2 = await node2Client2.ServerInformation(cancellationToken); - var node1Info2 = await node1Client2.ServerInformation(cancellationToken); + var node2Info2 = await node2Client2.RestClient.ServerInformation(cancellationToken); + var node1Info2 = await node1Client2.RestClient.ServerInformation(cancellationToken); CheckInfo(node1Info2); CheckInfo(node2Info2); @@ -791,7 +800,7 @@ namespace Tgstation.Server.Tests.Live gitHubToken); var downloadStream = await download.GetResult(cancellationToken); - var responseModel = await controllerClient2.Administration.Update( + var responseModel = await controllerClient2.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion, @@ -874,7 +883,7 @@ namespace Tgstation.Server.Tests.Live await using var node1Client = await CreateAdminClient(node1.ApiUrl, cancellationToken); await using var node2Client = await CreateAdminClient(node2.ApiUrl, cancellationToken); - var controllerInfo = await controllerClient.ServerInformation(cancellationToken); + var controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken); async Task WaitForSwarmServerUpdate(IRestServerClient client, int currentServerCount) { @@ -915,11 +924,11 @@ namespace Tgstation.Server.Tests.Live // wait a few minutes for the updated server list to dispatch await Task.WhenAny( - WaitForSwarmServerUpdate(node1Client, 1), + WaitForSwarmServerUpdate(node1Client.RestClient, 1), Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); - var node2Info = await node2Client.ServerInformation(cancellationToken); - var node1Info = await node1Client.ServerInformation(cancellationToken); + var node2Info = await node2Client.RestClient.ServerInformation(cancellationToken); + var node1Info = await node1Client.RestClient.ServerInformation(cancellationToken); CheckInfo(node1Info); CheckInfo(node2Info); @@ -931,21 +940,21 @@ namespace Tgstation.Server.Tests.Live Assert.IsTrue(node1Task.IsCompleted); // it should unregister - controllerInfo = await controllerClient.ServerInformation(cancellationToken); + controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken); Assert.AreEqual(2, controllerInfo.SwarmServers.Count); Assert.IsFalse(controllerInfo.SwarmServers.Any(x => x.Identifier == "node1")); // wait a few minutes for the updated server list to dispatch await Task.WhenAny( - WaitForSwarmServerUpdate(node2Client, 3), + WaitForSwarmServerUpdate(node2Client.RestClient, 3), Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); - node2Info = await node2Client.ServerInformation(cancellationToken); + node2Info = await node2Client.RestClient.ServerInformation(cancellationToken); Assert.AreEqual(2, node2Info.SwarmServers.Count); Assert.IsFalse(node2Info.SwarmServers.Any(x => x.Identifier == "node1")); // restart the controller - await controllerClient.Administration.Restart(cancellationToken); + await controllerClient.RestClient.Administration.Restart(cancellationToken); await Task.WhenAny( controllerTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); @@ -956,10 +965,10 @@ namespace Tgstation.Server.Tests.Live // node 2 should reconnect once it's health check triggers await Task.WhenAny( - WaitForSwarmServerUpdate(controllerClient2, 1), + WaitForSwarmServerUpdate(controllerClient2.RestClient, 1), Task.Delay(TimeSpan.FromMinutes(5), cancellationToken)); - controllerInfo = await controllerClient2.ServerInformation(cancellationToken); + controllerInfo = await controllerClient2.RestClient.ServerInformation(cancellationToken); Assert.AreEqual(2, controllerInfo.SwarmServers.Count); Assert.IsNotNull(controllerInfo.SwarmServers.SingleOrDefault(x => x.Identifier == "node2")); @@ -967,36 +976,36 @@ namespace Tgstation.Server.Tests.Live await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); // restart node2 - await node2Client.Administration.Restart(cancellationToken); + await node2Client.RestClient.Administration.Restart(cancellationToken); await Task.WhenAny( node2Task, Task.Delay(TimeSpan.FromMinutes(1))); Assert.IsTrue(node1Task.IsCompleted); // should have unregistered - controllerInfo = await controllerClient2.ServerInformation(cancellationToken); + controllerInfo = await controllerClient2.RestClient.ServerInformation(cancellationToken); Assert.AreEqual(1, controllerInfo.SwarmServers.Count); Assert.IsNull(controllerInfo.SwarmServers.SingleOrDefault(x => x.Identifier == "node2")); // update should fail await ApiAssert.ThrowsException( - () => controllerClient2.Administration.Update(new ServerUpdateRequest + () => controllerClient2.RestClient.Administration.Update(new ServerUpdateRequest { NewVersion = TestUpdateVersion }, null, cancellationToken), - ErrorCode.SwarmIntegrityCheckFailed); + Api.Models.ErrorCode.SwarmIntegrityCheckFailed); node2Task = node2.Run(cancellationToken).AsTask(); await using var node2Client2 = await CreateAdminClient(node2.ApiUrl, cancellationToken); // should re-register await Task.WhenAny( - WaitForSwarmServerUpdate(node2Client2, 1), + WaitForSwarmServerUpdate(node2Client2.RestClient, 1), Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); - node2Info = await node2Client2.ServerInformation(cancellationToken); + node2Info = await node2Client2.RestClient.ServerInformation(cancellationToken); Assert.AreEqual(2, node2Info.SwarmServers.Count); Assert.IsNotNull(node2Info.SwarmServers.SingleOrDefault(x => x.Identifier == "controller")); } @@ -1047,10 +1056,10 @@ namespace Tgstation.Server.Tests.Live try { await using var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken); - - var instanceManagerTest = new InstanceManagerTest(adminClient, server.Directory); + var restAdminClient = adminClient.RestClient; + var instanceManagerTest = new InstanceManagerTest(restAdminClient, server.Directory); var instance = await instanceManagerTest.CreateTestInstance("TgTestInstance", cancellationToken); - var instanceClient = adminClient.Instances.CreateClient(instance); + var instanceClient = restAdminClient.Instances.CreateClient(instance); var ddUpdateTask = instanceClient.DreamDaemon.Update(new DreamDaemonRequest @@ -1073,7 +1082,7 @@ namespace Tgstation.Server.Tests.Live if (!String.IsNullOrWhiteSpace(localRepoPath)) { await ioManager.CopyDirectory( - Enumerable.Empty(), + [], (src, dest) => { if (postWriteHandler.NeedsPostWrite(src)) @@ -1141,7 +1150,7 @@ namespace Tgstation.Server.Tests.Live cancellationToken); var scriptsCopyTask = ioManager.CopyDirectory( - Enumerable.Empty(), + [], (src, dest) => { if (postWriteHandler.NeedsPostWrite(src)) @@ -1353,62 +1362,67 @@ namespace Tgstation.Server.Tests.Live var serverTask = server.Run(cancellationToken).AsTask(); var fileDownloader = ((Host.Server)server.RealServer).Host.Services.GetRequiredService(); + var graphQLClientFactory = new GraphQLServerClientFactory(restClientFactory); try { Api.Models.Instance instance; long initialStaged, initialActive, initialSessionId; - await using var firstAdminClient = await CreateAdminClient(server.ApiUrl, cancellationToken); - await using (var tokenOnlyClient = clientFactory.CreateFromToken(server.RootUrl, firstAdminClient.Token)) + await using var firstAdminMultiClient = await CreateAdminClient(server.ApiUrl, cancellationToken); + + var firstAdminRestClient = firstAdminMultiClient.RestClient; + + if (MultiServerClient.UseGraphQL) + await using (var tokenOnlyGraphQLClient = graphQLClientFactory.CreateFromToken(server.RootUrl, firstAdminRestClient.Token.Bearer)) + { + // just testing auth works the same here + var result = await tokenOnlyGraphQLClient.RunOperation(client => client.ServerVersion.ExecuteAsync(cancellationToken), cancellationToken); + Assert.IsTrue(result.IsSuccessResult()); + } + + await using (var tokenOnlyRestClient = restClientFactory.CreateFromToken(server.RootUrl, firstAdminRestClient.Token)) { // regression test for password change issue - var currentUser = await tokenOnlyClient.Users.Read(cancellationToken); - var updatedUser = await tokenOnlyClient.Users.Update(new UserUpdateRequest + var currentUser = await tokenOnlyRestClient.Users.Read(cancellationToken); + var updatedUser = await tokenOnlyRestClient.Users.Update(new UserUpdateRequest { Id = currentUser.Id, Password = DefaultCredentials.DefaultAdminUserPassword, }, cancellationToken); - await ApiAssert.ThrowsException(() => tokenOnlyClient.Users.Read(cancellationToken), null); + await ApiAssert.ThrowsException(() => tokenOnlyRestClient.Users.Read(cancellationToken), null); } // basic graphql test, to be used everywhere eventually - await using (var graphQLClient = new GraphQLServerClientFactory(clientFactory).CreateUnauthenticated(server.RootUrl)) - { - // test getting server info - var multiClient = new MultiServerClient(firstAdminClient, graphQLClient); + if (MultiServerClient.UseGraphQL) + await using (var unauthenticatedGraphQLClient = graphQLClientFactory.CreateUnauthenticated(server.RootUrl)) + { + // check auth works as expected + var result = await unauthenticatedGraphQLClient.RunOperation(client => client.ServerVersion.ExecuteAsync(cancellationToken), cancellationToken); + Assert.IsTrue(result.IsErrorResult()); - await multiClient.ExecuteReadOnlyConfirmEquivalence( - restClient => restClient.ServerInformation(cancellationToken), - async gqlClient => (await gqlClient.ServerInformationQuery.ExecuteAsync(cancellationToken)).Data, - (restServerInfo, gqlServerInfo) => restServerInfo.UpdateInProgress == gqlServerInfo.Swarm.Metadata.UpdateInProgress - && restServerInfo.Version == gqlServerInfo.Swarm.Metadata.Version - && restServerInfo.DMApiVersion == gqlServerInfo.Swarm.Metadata.DmApiVersion - && restServerInfo.InstanceLimit == gqlServerInfo.Swarm.LocalServer.Information.InstanceLimit - && restServerInfo.UserGroupLimit == gqlServerInfo.Swarm.LocalServer.Information.UserGroupLimit - && restServerInfo.ValidInstancePaths.SequenceEqual(gqlServerInfo.Swarm.LocalServer.Information.ValidInstancePaths) - && restServerInfo.UserLimit == gqlServerInfo.Swarm.LocalServer.Information.UserLimit - && restServerInfo.MinimumPasswordLength == gqlServerInfo.Swarm.LocalServer.Information.MinimumPasswordLength - && (restServerInfo.SwarmServers == gqlServerInfo.Swarm.Servers - || restServerInfo.SwarmServers.SequenceEqual(gqlServerInfo.Swarm.Servers.Select(x => new SwarmServerResponse(new Api.Models.Internal.SwarmServerInformation - { - Address = x.Address, - PublicAddress = x.PublicAddress, - Controller = x.Controller, - Identifier = x.Identifier, - })))) - && (restServerInfo.OAuthProviderInfos == gqlServerInfo.Swarm.LocalServer.Information.OAuthProviderInfos - || restServerInfo.OAuthProviderInfos.All(kvp => - { - var info = gqlServerInfo.Swarm.LocalServer.Information.OAuthProviderInfos.FirstOrDefault(x => (int)x.Key == (int)kvp.Key); - return info != null - && info.Value.ServerUrl == kvp.Value.ServerUrl - && info.Value.ClientId == kvp.Value.ClientId - && info.Value.RedirectUri == kvp.Value.RedirectUri; - }))); - } + // test getting server info + var unAuthedMultiClient = new MultiServerClient(firstAdminRestClient, unauthenticatedGraphQLClient); - async ValueTask CreateUserWithNoInstancePerms() + await unauthenticatedGraphQLClient.RunQueryEnsureNoErrors( + gqlClient => gqlClient.UnauthenticatedServerInformation.ExecuteAsync(cancellationToken), + cancellationToken); + + var testObserver = new HoldLastObserver>(); + using var subscription = await unauthenticatedGraphQLClient.Subscribe( + gql => gql.SessionInvalidation.Watch(), + testObserver, + cancellationToken); + + await Task.Delay(1000, cancellationToken); + + Assert.AreEqual(0U, testObserver.ErrorCount); + Assert.AreEqual(1U, testObserver.ResultCount); + Assert.IsTrue(testObserver.LastValue.IsAuthenticationError()); + Assert.IsTrue(testObserver.Completed); + } + + async ValueTask CreateUserWithNoInstancePerms() { var createRequest = new UserCreateRequest() { @@ -1421,13 +1435,15 @@ namespace Tgstation.Server.Tests.Live } }; - var user = await firstAdminClient.Users.Create(createRequest, cancellationToken); + var user = await firstAdminRestClient.Users.Create(createRequest, cancellationToken); Assert.IsTrue(user.Enabled); - return await clientFactory.CreateFromLogin(server.RootUrl, createRequest.Name, createRequest.Password, cancellationToken: cancellationToken); + return await CreateClient(server.RootUrl, createRequest.Name, createRequest.Password, false, cancellationToken); } - var jobsHubTest = new JobsHubTests(firstAdminClient, await CreateUserWithNoInstancePerms()); + var restartObserver = new HoldLastObserver>(); + IDisposable restartSubscription; + var jobsHubTest = new JobsHubTests(firstAdminMultiClient, await CreateUserWithNoInstancePerms()); Task jobsHubTestTask; { if (server.DumpOpenApiSpecpath) @@ -1462,12 +1478,18 @@ namespace Tgstation.Server.Tests.Live InstanceResponse odInstance, compatInstance; if (!openDreamOnly) { - jobsHubTestTask = FailFast(await jobsHubTest.Run(cancellationToken)); // returns Task - var rootTest = FailFast(RawRequestTests.Run(clientFactory, firstAdminClient, cancellationToken)); - var adminTest = FailFast(new AdministrationTest(firstAdminClient.Administration).Run(cancellationToken)); - var usersTest = FailFast(new UsersTest(firstAdminClient).Run(cancellationToken)); + // force a session refresh if necessary + if (MultiServerClient.UseGraphQL) + await firstAdminMultiClient.GraphQLClient.RunQueryEnsureNoErrors( + gql => gql.ReadCurrentUser.ExecuteAsync(cancellationToken), + cancellationToken); - var instanceManagerTest = new InstanceManagerTest(firstAdminClient, server.Directory); + jobsHubTestTask = FailFast(await jobsHubTest.Run(cancellationToken)); // returns Task + var rootTest = FailFast(RawRequestTests.Run(restClientFactory, firstAdminRestClient, cancellationToken)); + var adminTest = FailFast(new AdministrationTest(firstAdminRestClient.Administration).Run(cancellationToken)); + var usersTest = FailFast(new UsersTest(firstAdminMultiClient).Run(cancellationToken).AsTask()); + + var instanceManagerTest = new InstanceManagerTest(firstAdminRestClient, server.Directory); var compatInstanceTask = instanceManagerTest.CreateTestInstance("CompatTestsInstance", cancellationToken); var odInstanceTask = instanceManagerTest.CreateTestInstance("OdTestsInstance", cancellationToken); var byondApiCompatInstanceTask = instanceManagerTest.CreateTestInstance("BCAPITestsInstance", cancellationToken); @@ -1477,7 +1499,7 @@ namespace Tgstation.Server.Tests.Live var byondApiCompatInstance = await byondApiCompatInstanceTask; var instancesTest = FailFast(instanceManagerTest.RunPreTest(cancellationToken)); Assert.IsTrue(Directory.Exists(instance.Path)); - instanceClient = firstAdminClient.Instances.CreateClient(instance); + instanceClient = firstAdminRestClient.Instances.CreateClient(instance); Assert.IsTrue(Directory.Exists(instanceClient.Metadata.Path)); nonInstanceTests = Task.WhenAll(instancesTest, adminTest, rootTest, usersTest); @@ -1488,13 +1510,13 @@ namespace Tgstation.Server.Tests.Live nonInstanceTests = Task.CompletedTask; jobsHubTestTask = null; instance = null; - var instanceManagerTest = new InstanceManagerTest(firstAdminClient, server.Directory); + var instanceManagerTest = new InstanceManagerTest(firstAdminRestClient, server.Directory); var odInstanceTask = instanceManagerTest.CreateTestInstance("OdTestsInstance", cancellationToken); odInstance = await odInstanceTask; } var instanceTest = new InstanceTest( - firstAdminClient.Instances, + firstAdminRestClient.Instances, fileDownloader, GetInstanceManager(), (ushort)server.ApiUrl.Port); @@ -1517,13 +1539,13 @@ namespace Tgstation.Server.Tests.Live server.OpenDreamUrl, cancellationToken).AsTask()); - Assert.AreEqual(ErrorCode.OpenDreamTooOld, ex.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.OpenDreamTooOld, ex.ErrorCode); await instanceTest .RunCompatTests( await edgeODVersionTask, server.OpenDreamUrl, - firstAdminClient.Instances.CreateClient(odInstance), + firstAdminRestClient.Instances.CreateClient(odInstance), odDMPort.Value, odDDPort.Value, server.HighPriorityDreamDaemon, @@ -1550,7 +1572,7 @@ namespace Tgstation.Server.Tests.Live : new Version(512, 1451) // http://www.byond.com/forum/?forum=5&command=search&scope=local&text=resolved%3a512.1451 }, server.OpenDreamUrl, - firstAdminClient.Instances.CreateClient(compatInstance), + firstAdminRestClient.Instances.CreateClient(compatInstance), compatDMPort.Value, compatDDPort.Value, server.HighPriorityDreamDaemon, @@ -1590,12 +1612,53 @@ namespace Tgstation.Server.Tests.Live initialStaged = dd.StagedCompileJob.Id.Value; initialSessionId = dd.SessionId.Value; - jobsHubTest.ExpectShutdown(); - await firstAdminClient.Administration.Restart(cancellationToken); + // force a session refresh if necessary + if (MultiServerClient.UseGraphQL) + { + await firstAdminMultiClient.GraphQLClient.RunQueryEnsureNoErrors( + gql => gql.ReadCurrentUser.ExecuteAsync(cancellationToken), + cancellationToken); + + restartSubscription = await firstAdminMultiClient.GraphQLClient.Subscribe( + gql => gql.SessionInvalidation.Watch(), + restartObserver, + cancellationToken); + } + else + restartSubscription = null; + + try + { + await Task.Delay(1000, cancellationToken); + + jobsHubTest.ExpectShutdown(); + await firstAdminRestClient.Administration.Restart(cancellationToken); + } + catch + { + restartSubscription?.Dispose(); + throw; + } } - await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); - Assert.IsTrue(serverTask.IsCompleted); + try + { + await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); + Assert.IsTrue(serverTask.IsCompleted); + + if (MultiServerClient.UseGraphQL) + { + Assert.AreEqual(0U, restartObserver.ErrorCount); + Assert.AreEqual(1U, restartObserver.ResultCount); + restartObserver.LastValue.EnsureNoErrors(); + Assert.IsTrue(restartObserver.Completed); + Assert.AreEqual(SessionInvalidationReason.ServerShutdown, restartObserver.LastValue.Data.SessionInvalidated); + } + } + finally + { + restartSubscription?.Dispose(); + } // test the reattach message queueing // for the code coverage really... @@ -1640,8 +1703,10 @@ namespace Tgstation.Server.Tests.Live // chat bot start and DD reattach test serverTask = server.Run(cancellationToken).AsTask(); - await using (var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken)) + await using (var multiClient = await CreateAdminClient(server.ApiUrl, cancellationToken)) { + var adminClient = multiClient.RestClient; + await jobsHubTest.WaitForReconnect(cancellationToken); var instanceClient = adminClient.Instances.CreateClient(instance); @@ -1745,8 +1810,9 @@ namespace Tgstation.Server.Tests.Live var edgeVersion = await EngineTest.GetEdgeVersion(EngineType.Byond, fileDownloader, cancellationToken); await using (var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken)) { + var restAdminClient = adminClient.RestClient; await jobsHubTest.WaitForReconnect(cancellationToken); - var instanceClient = adminClient.Instances.CreateClient(instance); + var instanceClient = restAdminClient.Instances.CreateClient(instance); await WaitForInitialJobs(instanceClient); var dd = await instanceClient.DreamDaemon.Read(cancellationToken); @@ -1778,7 +1844,7 @@ namespace Tgstation.Server.Tests.Live expectedStaged = compileJob.Id.Value; jobsHubTest.ExpectShutdown(); - await adminClient.Administration.Restart(cancellationToken); + await restAdminClient.Administration.Restart(cancellationToken); } await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); @@ -1788,8 +1854,9 @@ namespace Tgstation.Server.Tests.Live serverTask = server.Run(cancellationToken).AsTask(); await using (var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken)) { + var restAdminClient = adminClient.RestClient; await jobsHubTest.WaitForReconnect(cancellationToken); - var instanceClient = adminClient.Instances.CreateClient(instance); + var instanceClient = restAdminClient.Instances.CreateClient(instance); await WaitForInitialJobs(instanceClient); var currentDD = await instanceClient.DreamDaemon.Read(cancellationToken); @@ -1804,7 +1871,7 @@ namespace Tgstation.Server.Tests.Live await using var repoTestObj = new RepositoryTest(instanceClient.Repository, instanceClient.Jobs); var repoTest = repoTestObj.RunPostTest(cancellationToken); - await using var chatTestObj = new ChatTest(instanceClient.ChatBots, adminClient.Instances, instanceClient.Jobs, instance); + await using var chatTestObj = new ChatTest(instanceClient.ChatBots, restAdminClient.Instances, instanceClient.Jobs, instance); await chatTestObj.RunPostTest(cancellationToken); await repoTest; @@ -1813,7 +1880,7 @@ namespace Tgstation.Server.Tests.Live jobsHubTest.CompleteNow(); await jobsHubTestTask; - await new InstanceManagerTest(adminClient, server.Directory).RunPostTest(instance, cancellationToken); + await new InstanceManagerTest(restAdminClient, server.Directory).RunPostTest(instance, cancellationToken); } } catch (ApiException ex) @@ -1848,32 +1915,86 @@ namespace Tgstation.Server.Tests.Live await serverTask; } - async Task CreateAdminClient(Uri url, CancellationToken cancellationToken) + ValueTask CreateAdminClient(Uri url, CancellationToken cancellationToken) + => CreateClient(url, DefaultCredentials.AdminUserName, DefaultCredentials.DefaultAdminUserPassword, true, cancellationToken); + + async ValueTask CreateClient( + Uri url, + string username, + string password, + bool retry, + CancellationToken cancellationToken = default) { url = new Uri(url.ToString().Replace(Routes.ApiRoot, String.Empty)); var giveUpAt = DateTimeOffset.UtcNow.AddMinutes(2); for (var I = 1; ; ++I) { + ValueTask restClientTask; + ValueTask graphQLClientTask; try { - System.Console.WriteLine($"TEST: CreateAdminClient attempt {I}..."); - return await clientFactory.CreateFromLogin( + Console.WriteLine($"TEST: CreateAdminClient attempt {I}..."); + + restClientTask = restClientFactory.CreateFromLogin( url, - DefaultCredentials.AdminUserName, - DefaultCredentials.DefaultAdminUserPassword, + username, + password, cancellationToken: cancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + graphQLClientTask = MultiServerClient.UseGraphQL + ? graphQLClientFactory.CreateFromLogin( + url, + username, + password, + cancellationToken: cts.Token) + : ValueTask.FromResult(null); + + IRestServerClient restClient; + try + { + restClient = await restClientTask; + } + catch (Exception restException) when (restException is not HttpRequestException && restException is not ServiceUnavailableException) + { + cts.Cancel(); + try + { + await (await graphQLClientTask).DisposeAsync(); + } + catch (OperationCanceledException) + { + } + catch (Exception graphQLException) + { + throw new AggregateException(restException, graphQLException); + } + + throw; + } + + try + { + return new MultiServerClient( + restClient, + await graphQLClientTask); + } + catch + { + await restClient.DisposeAsync(); + throw; + } } catch (HttpRequestException) { //migrating, to be expected - if (DateTimeOffset.UtcNow > giveUpAt) + if (DateTimeOffset.UtcNow > giveUpAt || !retry) throw; await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); } catch (ServiceUnavailableException) { // migrating, to be expected - if (DateTimeOffset.UtcNow > giveUpAt) + if (DateTimeOffset.UtcNow > giveUpAt || !retry) throw; await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); } diff --git a/tests/Tgstation.Server.Tests/Live/UsersTest.cs b/tests/Tgstation.Server.Tests/Live/UsersTest.cs index ceebac4356..8d918804fe 100644 --- a/tests/Tgstation.Server.Tests/Live/UsersTest.cs +++ b/tests/Tgstation.Server.Tests/Live/UsersTest.cs @@ -1,5 +1,11 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Elastic.CommonSchema; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using StrawberryShake; + using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -10,238 +16,580 @@ using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; using Tgstation.Server.Client; +using Tgstation.Server.Client.GraphQL; using Tgstation.Server.Common.Extensions; +using Tgstation.Server.Host.Controllers; using Tgstation.Server.Host.System; namespace Tgstation.Server.Tests.Live { sealed class UsersTest { - readonly IRestServerClient serverClient; + readonly IMultiServerClient serverClient; - public UsersTest(IRestServerClient serverClient) + public UsersTest(IMultiServerClient serverClient) { this.serverClient = serverClient ?? throw new ArgumentNullException(nameof(serverClient)); } - public async Task Run(CancellationToken cancellationToken) + public async ValueTask Run(CancellationToken cancellationToken) { - await Task.WhenAll( - BasicTests(cancellationToken), + var observer = new HoldLastObserver>(); + using var subscription = await serverClient.Subscribe( + gql => gql.SubscribeUsers.Watch(), + observer, + cancellationToken); + + await BasicTests(cancellationToken); + + await ValueTaskExtensions.WhenAll( TestCreateSysUser(cancellationToken), TestSpamCreation(cancellationToken)); await TestPagination(cancellationToken); + + if (!MultiServerClient.UseGraphQL) + return; + + Assert.IsFalse(observer.Completed); + Assert.AreEqual(0U, observer.ErrorCount); + + var expected = new PlatformIdentifier().IsWindows ? 108U : 107U; + // wait up to 10s + var lastSeen = observer.ResultCount; + for (var i = 0; i < 10 && observer.ResultCount < expected; ++i) + { + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + if (observer.ResultCount > lastSeen) + { + lastSeen = observer.ResultCount; + i = -1; + } + } + + Assert.AreEqual(expected, observer.ResultCount); // sys user + observer.LastValue.EnsureNoErrors(); } - async Task BasicTests(CancellationToken cancellationToken) + async ValueTask BasicTests(CancellationToken cancellationToken) { - var user = await serverClient.Users.Read(cancellationToken); - Assert.IsNotNull(user); - Assert.AreEqual("Admin", user.Name); - Assert.IsNull(user.SystemIdentifier); - Assert.AreEqual(true, user.Enabled); - Assert.IsNotNull(user.OAuthConnections); - Assert.IsNotNull(user.PermissionSet); - Assert.IsNotNull(user.PermissionSet.Id); - Assert.IsNotNull(user.PermissionSet.InstanceManagerRights); - Assert.IsNotNull(user.PermissionSet.AdministrationRights); + var (restUser, gqlUser) = await serverClient.ExecuteReadOnlyConfirmEquivalence( + client => client.Users.Read(cancellationToken), + client => client.ReadCurrentUser.ExecuteAsync(cancellationToken), + (restResult, graphQLResult) => + { + var gqlUser = graphQLResult.Swarm.Users.Current; + return restResult.Enabled == gqlUser.Enabled + && restResult.Name == gqlUser.Name + && (restResult.CreatedAt.Value.Ticks / 1000000) == (gqlUser.CreatedAt.Ticks / 1000000) + && restResult.SystemIdentifier == gqlUser.SystemIdentifier + && restResult.CreatedBy.Name == gqlUser.CreatedBy.Name; + }, + cancellationToken); - var systemUser = user.CreatedBy; + Assert.IsNotNull(restUser); + Assert.AreEqual("Admin", restUser.Name); + Assert.IsNull(restUser.SystemIdentifier); + Assert.AreEqual(true, restUser.Enabled); + Assert.IsNotNull(restUser.OAuthConnections); + Assert.IsNotNull(restUser.PermissionSet); + Assert.IsNotNull(restUser.PermissionSet.Id); + Assert.IsNotNull(restUser.PermissionSet.InstanceManagerRights); + Assert.IsNotNull(restUser.PermissionSet.AdministrationRights); + + var systemUser = restUser.CreatedBy; Assert.IsNotNull(systemUser); Assert.AreEqual("TGS", systemUser.Name); - var users = await serverClient.Users.List(null, cancellationToken); - Assert.IsTrue(users.Count > 0); - Assert.IsFalse(users.Any(x => x.Id == systemUser.Id)); - - await ApiAssert.ThrowsException(() => serverClient.Users.GetId(systemUser, cancellationToken)); - await ApiAssert.ThrowsException(() => serverClient.Users.Update(new UserUpdateRequest - { - Id = systemUser.Id - }, cancellationToken)); - - var sampleOAuthConnections = new List - { - new OAuthConnection + await serverClient.Execute( + async client => { - ExternalUserId = "asdfasdf", - Provider = OAuthProvider.Discord - } - }; - await ApiAssert.ThrowsException(() => serverClient.Users.Update(new UserUpdateRequest - { - Id = user.Id, - OAuthConnections = sampleOAuthConnections - }, cancellationToken), ErrorCode.AdminUserCannotOAuth); + var users = await client.Users.List(null, cancellationToken); + Assert.IsTrue(users.Count > 0); + Assert.IsFalse(users.Any(x => x.Id == systemUser.Id)); - var testUser = await serverClient.Users.Create( - new UserCreateRequest - { - Name = $"BasicTestUser", - Password = "asdfasdjfhauwiehruiy273894234jhndjkwh" + await ApiAssert.ThrowsException(() => client.Users.GetId(systemUser, cancellationToken)); + await ApiAssert.ThrowsException(() => client.Users.Update(new UserUpdateRequest + { + Id = systemUser.Id + }, cancellationToken)); + + var sampleOAuthConnections = new List + { + new() + { + ExternalUserId = "asdfasdf", + Provider = Api.Models.OAuthProvider.Discord + } + }; + + await ApiAssert.ThrowsException(() => client.Users.Update(new UserUpdateRequest + { + Id = restUser.Id, + OAuthConnections = sampleOAuthConnections + }, cancellationToken), Api.Models.ErrorCode.AdminUserCannotOAuth); + + var testUser = await client.Users.Create( + new UserCreateRequest + { + Name = $"BasicTestUser", + Password = "asdfasdjfhauwiehruiy273894234jhndjkwh" + }, + cancellationToken); + + Assert.IsNotNull(testUser.OAuthConnections); + testUser = await client.Users.Update( + new UserUpdateRequest + { + Id = testUser.Id, + OAuthConnections = sampleOAuthConnections + }, + cancellationToken); + + Assert.AreEqual(1, testUser.OAuthConnections.Count); + Assert.AreEqual(sampleOAuthConnections.First().ExternalUserId, testUser.OAuthConnections.First().ExternalUserId); + Assert.AreEqual(sampleOAuthConnections.First().Provider, testUser.OAuthConnections.First().Provider); + + var group = await client.Groups.Create( + new UserGroupCreateRequest + { + Name = "TestGroup" + }, + cancellationToken); + Assert.AreEqual(group.Name, "TestGroup"); + Assert.IsNotNull(group.PermissionSet); + Assert.IsNotNull(group.PermissionSet.Id); + Assert.AreEqual(AdministrationRights.None, group.PermissionSet.AdministrationRights); + Assert.AreEqual(InstanceManagerRights.None, group.PermissionSet.InstanceManagerRights); + + var group2 = await client.Groups.Create(new UserGroupCreateRequest + { + Name = "TestGroup2", + PermissionSet = new PermissionSet + { + InstanceManagerRights = InstanceManagerRights.List + } + }, cancellationToken); + Assert.AreEqual(AdministrationRights.None, group2.PermissionSet.AdministrationRights); + Assert.AreEqual(InstanceManagerRights.List, group2.PermissionSet.InstanceManagerRights); + + var groups = await client.Groups.List(null, cancellationToken); + Assert.AreEqual(2, groups.Count); + + foreach (var igroup in groups) + { + Assert.IsNotNull(igroup.Users); + Assert.IsNotNull(igroup.PermissionSet); + } + + await client.Groups.Delete(group2, cancellationToken); + + groups = await client.Groups.List(null, cancellationToken); + Assert.AreEqual(1, groups.Count); + + group = await client.Groups.Update(new UserGroupUpdateRequest + { + Id = groups[0].Id, + PermissionSet = new PermissionSet + { + InstanceManagerRights = RightsHelper.AllRights(), + AdministrationRights = RightsHelper.AllRights(), + } + }, cancellationToken); + + Assert.AreEqual(RightsHelper.AllRights(), group.PermissionSet.AdministrationRights); + Assert.AreEqual(RightsHelper.AllRights(), group.PermissionSet.InstanceManagerRights); + + UserUpdateRequest testUserUpdate = new UserCreateRequest + { + Name = "TestUserWithNoPassword", + Password = string.Empty + }; + + await ApiAssert.ThrowsException(() => client.Users.Create((UserCreateRequest)testUserUpdate, cancellationToken), Api.Models.ErrorCode.UserPasswordLength); + + testUserUpdate.OAuthConnections = + [ + new() + { + ExternalUserId = "asdf", + Provider = Api.Models.OAuthProvider.GitHub + } + ]; + + var testUser2 = await client.Users.Create((UserCreateRequest)testUserUpdate, cancellationToken); + + testUserUpdate = new UserUpdateRequest + { + Id = testUser2.Id, + PermissionSet = testUser2.PermissionSet, + Group = new Api.Models.Internal.UserGroup + { + Id = group.Id + }, + }; + await ApiAssert.ThrowsException( + () => client.Users.Update( + testUserUpdate, + cancellationToken), + Api.Models.ErrorCode.UserGroupAndPermissionSet); + + testUserUpdate.PermissionSet = null; + + testUser2 = await client.Users.Update(testUserUpdate, cancellationToken); + + Assert.IsNull(testUser2.PermissionSet); + Assert.IsNotNull(testUser2.Group); + Assert.AreEqual(group.Id, testUser2.Group.Id); + + var group4 = await client.Groups.GetId(group, cancellationToken); + Assert.IsNotNull(group4.Users); + Assert.AreEqual(1, group4.Users.Count); + Assert.AreEqual(testUser2.Id, group4.Users.First().Id); + Assert.IsNotNull(group4.PermissionSet); + + testUserUpdate.Group = null; + testUserUpdate.PermissionSet = new PermissionSet + { + AdministrationRights = RightsHelper.AllRights(), + InstanceManagerRights = RightsHelper.AllRights(), + }; + + testUser2 = await client.Users.Update(testUserUpdate, cancellationToken); + Assert.IsNull(testUser2.Group); + Assert.IsNotNull(testUser2.PermissionSet); }, - cancellationToken); - - Assert.IsNotNull(testUser.OAuthConnections); - testUser = await serverClient.Users.Update( - new UserUpdateRequest - { - Id = testUser.Id, - OAuthConnections = sampleOAuthConnections - }, - cancellationToken); - - Assert.AreEqual(1, testUser.OAuthConnections.Count); - Assert.AreEqual(sampleOAuthConnections.First().ExternalUserId, testUser.OAuthConnections.First().ExternalUserId); - Assert.AreEqual(sampleOAuthConnections.First().Provider, testUser.OAuthConnections.First().Provider); - - - var group = await serverClient.Groups.Create( - new UserGroupCreateRequest + async client => { - Name = "TestGroup" - }, - cancellationToken); - Assert.AreEqual(group.Name, "TestGroup"); - Assert.IsNotNull(group.PermissionSet); - Assert.IsNotNull(group.PermissionSet.Id); - Assert.AreEqual(AdministrationRights.None, group.PermissionSet.AdministrationRights); - Assert.AreEqual(InstanceManagerRights.None, group.PermissionSet.InstanceManagerRights); + var result = await client.RunOperation(gql => gql.ListUsers.ExecuteAsync(cancellationToken), cancellationToken); + result.EnsureNoErrors(); + var users = result.Data.Swarm.Users.QueryableUsers; + Assert.IsTrue(users.TotalCount > 0); + Assert.AreEqual(Math.Min(ApiController.DefaultPageSize, users.TotalCount), users.Nodes.Count); - var group2 = await serverClient.Groups.Create(new UserGroupCreateRequest - { - Name = "TestGroup2", - PermissionSet = new PermissionSet - { - InstanceManagerRights = InstanceManagerRights.List - } - }, cancellationToken); - Assert.AreEqual(AdministrationRights.None, group2.PermissionSet.AdministrationRights); - Assert.AreEqual(InstanceManagerRights.List, group2.PermissionSet.InstanceManagerRights); + var tgsUserResult = await client.RunOperation(gql => gql.GetUserNameByNodeId.ExecuteAsync(gqlUser.Swarm.Users.Current.CreatedBy.Id, cancellationToken), cancellationToken); + tgsUserResult.EnsureNoErrors(); + var tgsUserResult2 = await client.RunOperation(gql => gql.GetUserById.ExecuteAsync(gqlUser.Swarm.Users.Current.CreatedBy.Id, cancellationToken), cancellationToken); + Assert.IsTrue(tgsUserResult2.IsErrorResult()); - var groups = await serverClient.Groups.List(null, cancellationToken); - Assert.AreEqual(2, groups.Count); + var sampleOAuthConnections = new List + { + new() + { + ExternalUserId = "asdfasdf", + Provider = Client.GraphQL.OAuthProvider.Discord, + } + }; - foreach (var igroup in groups) - { - Assert.IsNotNull(igroup.Users); - Assert.IsNotNull(igroup.PermissionSet); - } + await ApiAssert.OperationFails( + client, + gql => gql.SetUserOAuthConnections.ExecuteAsync( + gqlUser.Swarm.Users.Current.Id, + sampleOAuthConnections, + cancellationToken), + data => data.UpdateUser, + Client.GraphQL.ErrorCode.AdminUserCannotOAuth, + cancellationToken); - await serverClient.Groups.Delete(group2, cancellationToken); + var testUserResult = await client.RunMutationEnsureNoErrors( + gql => gql.CreateUserWithPasswordSelectOAuthConnections.ExecuteAsync("BasicTestUser", "asdfasdjfhauwiehruiy273894234jhndjkwh", cancellationToken), + data => data.CreateUserByPasswordAndPermissionSet, + cancellationToken); - groups = await serverClient.Groups.List(null, cancellationToken); - Assert.AreEqual(1, groups.Count); + var testUserResult2 = await client.RunMutationEnsureNoErrors( + gql => gql.UpdateUserOAuthConnections.ExecuteAsync( + testUserResult.User.Id, + sampleOAuthConnections, + cancellationToken), + data => data.UpdateUser, + cancellationToken); - group = await serverClient.Groups.Update(new UserGroupUpdateRequest - { - Id = groups[0].Id, - PermissionSet = new PermissionSet - { - InstanceManagerRights = RightsHelper.AllRights(), - AdministrationRights = RightsHelper.AllRights(), - } - }, cancellationToken); + var testUser = testUserResult2.User; + Assert.IsNotNull(testUser.OAuthConnections); + Assert.AreEqual(1, testUser.OAuthConnections.Count); + Assert.AreEqual(sampleOAuthConnections.First().ExternalUserId, testUser.OAuthConnections[0].ExternalUserId); + Assert.AreEqual(sampleOAuthConnections.First().Provider, testUser.OAuthConnections[0].Provider); - Assert.AreEqual(RightsHelper.AllRights(), group.PermissionSet.AdministrationRights); - Assert.AreEqual(RightsHelper.AllRights(), group.PermissionSet.InstanceManagerRights); + var groupResult = await client.RunMutationEnsureNoErrors( + gql => gql.CreateUserGroup.ExecuteAsync("TestGroup", cancellationToken), + data => data.CreateUserGroup, + cancellationToken); - UserUpdateRequest testUserUpdate = new UserCreateRequest - { - Name = "TestUserWithNoPassword", - Password = string.Empty - }; + var group = groupResult.UserGroup; + Assert.AreEqual(group.Name, "TestGroup"); + Assert.IsNotNull(group.PermissionSet); - await ApiAssert.ThrowsException(() => serverClient.Users.Create((UserCreateRequest)testUserUpdate, cancellationToken), ErrorCode.UserPasswordLength); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanSetOnline); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanRelocate); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanRename); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanSetConfiguration); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanRead); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanCreate); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanDelete); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanGrantPermissions); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanList); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanSetAutoUpdate); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanSetChatBotLimit); + Assert.IsFalse(group.PermissionSet.InstanceManagerRights.CanSetConfiguration); - testUserUpdate.OAuthConnections = new List - { - new OAuthConnection - { - ExternalUserId = "asdf", - Provider = OAuthProvider.GitHub - } - }; + Assert.IsFalse(group.PermissionSet.AdministrationRights.CanChangeVersion); + Assert.IsFalse(group.PermissionSet.AdministrationRights.CanDownloadLogs); + Assert.IsFalse(group.PermissionSet.AdministrationRights.CanEditOwnOAuthConnections); + Assert.IsFalse(group.PermissionSet.AdministrationRights.CanEditOwnPassword); + Assert.IsFalse(group.PermissionSet.AdministrationRights.CanReadUsers); + Assert.IsFalse(group.PermissionSet.AdministrationRights.CanRestartHost); + Assert.IsFalse(group.PermissionSet.AdministrationRights.CanUploadVersion); + Assert.IsFalse(group.PermissionSet.AdministrationRights.CanWriteUsers); - var testUser2 = await serverClient.Users.Create((UserCreateRequest)testUserUpdate, cancellationToken); + var group2Result = await client.RunMutationEnsureNoErrors( + gql => gql.CreateUserGroupWithInstanceListPerm.ExecuteAsync("TestGroup2", cancellationToken), + data => data.CreateUserGroup, + cancellationToken); - testUserUpdate = new UserUpdateRequest - { - Id = testUser2.Id, - PermissionSet = testUser2.PermissionSet, - Group = new Api.Models.Internal.UserGroup - { - Id = group.Id - }, - }; - await ApiAssert.ThrowsException( - () => serverClient.Users.Update( - testUserUpdate, - cancellationToken), - ErrorCode.UserGroupAndPermissionSet); + var group2 = group2Result.UserGroup; - testUserUpdate.PermissionSet = null; + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanSetOnline); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanRelocate); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanRename); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanSetConfiguration); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanRead); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanCreate); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanDelete); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanGrantPermissions); + Assert.IsTrue(group2.PermissionSet.InstanceManagerRights.CanList); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanSetAutoUpdate); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanSetChatBotLimit); + Assert.IsFalse(group2.PermissionSet.InstanceManagerRights.CanSetConfiguration); - testUser2 = await serverClient.Users.Update(testUserUpdate, cancellationToken); + Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanChangeVersion); + Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanDownloadLogs); + Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanEditOwnOAuthConnections); + Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanEditOwnPassword); + Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanReadUsers); + Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanRestartHost); + Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanUploadVersion); + Assert.IsFalse(group2.PermissionSet.AdministrationRights.CanWriteUsers); - Assert.IsNull(testUser2.PermissionSet); - Assert.IsNotNull(testUser2.Group); - Assert.AreEqual(group.Id, testUser2.Group.Id); + var groupsResult = await client.RunQueryEnsureNoErrors( + gql => gql.ListUserGroups.ExecuteAsync(cancellationToken), + cancellationToken); - group = await serverClient.Groups.GetId(group, cancellationToken); - Assert.IsNotNull(group.Users); - Assert.AreEqual(1, group.Users.Count); - Assert.AreEqual(testUser2.Id, group.Users.First().Id); - Assert.IsNotNull(group.PermissionSet); + var groups = groupsResult.Swarm.Users.Groups.QueryableGroups; + Assert.AreEqual(2, groups.TotalCount); - testUserUpdate.Group = null; - testUserUpdate.PermissionSet = new PermissionSet - { - AdministrationRights = RightsHelper.AllRights(), - InstanceManagerRights = RightsHelper.AllRights(), - }; + foreach (var igroup in groups.Nodes) + Assert.IsNotNull(igroup.Id); - testUser2 = await serverClient.Users.Update(testUserUpdate, cancellationToken); - Assert.IsNull(testUser2.Group); - Assert.IsNotNull(testUser2.PermissionSet); + var deleteResult = await client.RunMutationEnsureNoErrors( + gql => gql.DeleteUserGroup.ExecuteAsync(group2.Id, cancellationToken), + data => data.DeleteEmptyUserGroup, + cancellationToken); + + groupsResult = await client.RunQueryEnsureNoErrors( + gql => gql.ListUserGroups.ExecuteAsync(cancellationToken), + cancellationToken); + + groups = groupsResult.Swarm.Users.Groups.QueryableGroups; + Assert.AreEqual(1, groups.TotalCount); + + foreach (var igroup in groups.Nodes) + Assert.IsNotNull(igroup.Id); + + var group3Result = await client.RunMutationEnsureNoErrors( + gql => gql.SetFullPermsOnUserGroup.ExecuteAsync(group.Id, cancellationToken), + data => data.UpdateUserGroup, + cancellationToken); + + var group3 = group3Result.UserGroup; + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanSetOnline); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanRelocate); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanRename); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanSetConfiguration); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanRead); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanCreate); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanDelete); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanGrantPermissions); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanList); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanSetAutoUpdate); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanSetChatBotLimit); + Assert.IsTrue(group3.PermissionSet.InstanceManagerRights.CanSetConfiguration); + + Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanChangeVersion); + Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanDownloadLogs); + Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanEditOwnOAuthConnections); + Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanEditOwnPassword); + Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanReadUsers); + Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanRestartHost); + Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanUploadVersion); + Assert.IsTrue(group3.PermissionSet.AdministrationRights.CanWriteUsers); + + await ApiAssert.OperationFails( + client, + gql => gql.CreateUserWithPassword.ExecuteAsync("TestUserWithNoPassword", String.Empty, cancellationToken), + data => data.CreateUserByPasswordAndPermissionSet, + Client.GraphQL.ErrorCode.ModelValidationFailure, + cancellationToken); + + await ApiAssert.OperationFails( + client, + gql => gql.CreateUserWithPassword.ExecuteAsync("TestUserWithShortPassword", "a", cancellationToken), + data => data.CreateUserByPasswordAndPermissionSet, + Client.GraphQL.ErrorCode.UserPasswordLength, + cancellationToken); + + var oAuthCreateResult = await client.RunMutationEnsureNoErrors( + gql => gql.CreateUserFromOAuthConnection.ExecuteAsync( + "TestUserWithNoPassword", + [ + new() + { + ExternalUserId = "asdf", + Provider = Client.GraphQL.OAuthProvider.GitHub, + } + ], + cancellationToken), + data => data.CreateUserByOAuthAndPermissionSet, + cancellationToken); + + var testUser2 = oAuthCreateResult.User; + + var testUser22Result = await client.RunMutationEnsureNoErrors( + gql => gql.SetUserGroup.ExecuteAsync(testUser2.Id, group.Id, cancellationToken), + data => data.UpdateUserSetGroup, + cancellationToken); + + var testUser22 = testUser22Result.User; + + Assert.IsNull(testUser22.OwnedPermissionSet); + Assert.IsNotNull(testUser22.Group); + Assert.AreEqual(group.Id, testUser22.Group.Id); + + var group4Result = await client.RunQueryEnsureNoErrors( + gql => gql.GetSomeGroupInfo.ExecuteAsync(group.Id, cancellationToken), + cancellationToken); + var group4 = group4Result.Swarm.Users.Groups.ById; + + Assert.IsNotNull(group4.QueryableUsersByGroup.Nodes); + Assert.AreEqual(1, group4.QueryableUsersByGroup.TotalCount); + Assert.AreEqual(testUser2.Id, group4.QueryableUsersByGroup.Nodes[0].Id); + Assert.IsNotNull(group4.PermissionSet); + + var testUser4Result = await client.RunMutationEnsureNoErrors( + gql => gql.SetUserPermissionSet.ExecuteAsync( + testUser2.Id, + new PermissionSetInput + { + AdministrationRights = new AdministrationRightsFlagsInput + { + CanChangeVersion = true, + CanDownloadLogs = true, + CanEditOwnOAuthConnections = true, + CanEditOwnPassword = true, + CanReadUsers = true, + CanRestartHost = true, + CanUploadVersion = true, + CanWriteUsers = true, + }, + InstanceManagerRights = new InstanceManagerRightsFlagsInput + { + CanCreate = true, + CanDelete = true, + CanGrantPermissions = true, + CanList = true, + CanRead = true, + CanRelocate = true, + CanRename = true, + CanSetAutoUpdate = true, + CanSetChatBotLimit = true, + CanSetConfiguration = true, + CanSetOnline = true, + } + }, + cancellationToken), + data => data.UpdateUserSetOwnedPermissionSet, + cancellationToken); + + var testUser4 = testUser4Result.User; + Assert.IsNull(testUser4.Group); + Assert.IsNotNull(testUser4.OwnedPermissionSet); + }); } - async Task TestCreateSysUser(CancellationToken cancellationToken) + ValueTask TestCreateSysUser(CancellationToken cancellationToken) { var sysId = Environment.UserName; - var update = new UserCreateRequest - { - SystemIdentifier = sysId - }; - if (new PlatformIdentifier().IsWindows) - await serverClient.Users.Create(update, cancellationToken); - else - await ApiAssert.ThrowsException(() => serverClient.Users.Create(update, cancellationToken), ErrorCode.RequiresPosixSystemIdentity); + + return serverClient.Execute( + async restClient => + { + var update = new UserCreateRequest + { + SystemIdentifier = sysId + }; + if (new PlatformIdentifier().IsWindows) + await restClient.Users.Create(update, cancellationToken); + else + await ApiAssert.ThrowsException(() => restClient.Users.Create(update, cancellationToken), Api.Models.ErrorCode.RequiresPosixSystemIdentity); + }, + async graphQLClient => + { + if (new PlatformIdentifier().IsWindows) + { + await graphQLClient.RunMutationEnsureNoErrors( + gql => gql.CreateSystemUserWithPermissionSet.ExecuteAsync(sysId, cancellationToken), + data => data.CreateUserBySystemIDAndPermissionSet, + cancellationToken); + } + else + await ApiAssert.OperationFails( + graphQLClient, + gql => gql.CreateSystemUserWithPermissionSet.ExecuteAsync(sysId, cancellationToken), + data => data.CreateUserBySystemIDAndPermissionSet, + Client.GraphQL.ErrorCode.RequiresPosixSystemIdentity, + cancellationToken); + }); } - async Task TestSpamCreation(CancellationToken cancellationToken) + async ValueTask TestSpamCreation(CancellationToken cancellationToken) { // Careful with this, very easy to overload the thread pool const int RepeatCount = 100; - var tasks = new List>(RepeatCount); + var tasks = new List(RepeatCount); ThreadPool.GetMaxThreads(out var defaultMaxWorker, out var defaultMaxCompletion); ThreadPool.GetMinThreads(out var defaultMinWorker, out var defaultMinCompletion); + ConcurrentBag ids = new ConcurrentBag(); try { ThreadPool.SetMinThreads(Math.Min(RepeatCount * 4, defaultMaxWorker), Math.Min(RepeatCount * 4, defaultMaxCompletion)); for (int i = 0; i < RepeatCount; ++i) { - var task = - serverClient.Users.Create( - new UserCreateRequest + var iLocal = i; + ValueTask CreateSpamUser() + => serverClient.Execute( + async restClient => { - Name = $"SpamTestUser_{i}", - Password = "asdfasdjfhauwiehruiy273894234jhndjkwh" + var user = await restClient.Users.Create( + new UserCreateRequest + { + Name = $"SpamTestUser_{iLocal}", + Password = "asdfasdjfhauwiehruiy273894234jhndjkwh" + }, + cancellationToken); + + ids.Add(user.Id); }, - cancellationToken); - tasks.Add(task); + async graphQLClient => + { + var result = await graphQLClient.RunMutationEnsureNoErrors( + gql => gql.CreateUserWithPassword.ExecuteAsync($"SpamTestUser_{iLocal}", "asdfasdjfhauwiehruiy273894234jhndjkwh", cancellationToken), + data => data.CreateUserByPasswordAndPermissionSet, + cancellationToken); + + ids.Add(result.User.Id); + }); + + tasks.Add(CreateSpamUser()); } await ValueTaskExtensions.WhenAll(tasks); @@ -251,57 +599,127 @@ namespace Tgstation.Server.Tests.Live ThreadPool.SetMinThreads(defaultMinWorker, defaultMinCompletion); } - Assert.AreEqual(RepeatCount, tasks.Select(task => task.Result.Id).Distinct().Count(), "Did not receive expected number of unique user IDs!"); + Assert.AreEqual(RepeatCount, ids.Distinct().Count(), "Did not receive expected number of unique user IDs!"); } - async Task TestPagination(CancellationToken cancellationToken) + ValueTask TestPagination(CancellationToken cancellationToken) { - // we test pagination here b/c it's the only spot we have a decent amount of entities - var nullSettings = await serverClient.Users.List(null, cancellationToken); - var emptySettings = await serverClient.Users.List( - new PaginationSettings + var expectedCount = new PlatformIdentifier().IsWindows ? 106 : 105; // system user + return serverClient.Execute( + async restClient => { - }, cancellationToken); + // we test pagination here b/c it's the only spot we have a decent amount of entities + var nullSettings = await restClient.Users.List(null, cancellationToken); + var emptySettings = await restClient.Users.List( + new PaginationSettings + { + }, cancellationToken); - Assert.AreEqual(nullSettings.Count, emptySettings.Count); - Assert.IsTrue(nullSettings.All(x => emptySettings.SingleOrDefault(y => x.Id == y.Id) != null)); + Assert.AreEqual(nullSettings.Count, emptySettings.Count); + Assert.AreEqual(expectedCount, nullSettings.Count); + Assert.IsTrue(nullSettings.All(x => emptySettings.SingleOrDefault(y => x.Id == y.Id) != null)); - await ApiAssert.ThrowsException>(() => serverClient.Users.List( - new PaginationSettings - { - PageSize = -2143 - }, cancellationToken), ErrorCode.ApiInvalidPageOrPageSize); - await ApiAssert.ThrowsException>(() => serverClient.Users.List( - new PaginationSettings - { - PageSize = int.MaxValue - }, cancellationToken), ErrorCode.ApiPageTooLarge); + await ApiAssert.ThrowsException>(() => restClient.Users.List( + new PaginationSettings + { + PageSize = -2143 + }, cancellationToken), Api.Models.ErrorCode.ApiInvalidPageOrPageSize); + await ApiAssert.ThrowsException>(() => restClient.Users.List( + new PaginationSettings + { + PageSize = ApiController.MaximumPageSize + 1, + }, cancellationToken), Api.Models.ErrorCode.ApiPageTooLarge); - await serverClient.Users.List( - new PaginationSettings - { - PageSize = 50 + var users = await restClient.Users.List( + new PaginationSettings + { + PageSize = ApiController.MaximumPageSize, + RetrieveCount = ApiController.MaximumPageSize, + }, + cancellationToken); + + Assert.AreEqual(ApiController.MaximumPageSize, users.Count); + + var skipped = await restClient.Users.List(new PaginationSettings + { + Offset = 50, + RetrieveCount = 5 + }, cancellationToken); + Assert.AreEqual(5, skipped.Count); + + var allAfterSkipped = await restClient.Users.List(new PaginationSettings + { + Offset = 50, + }, cancellationToken); + Assert.IsTrue(5 < allAfterSkipped.Count); + + var limited = await restClient.Users.List(new PaginationSettings + { + RetrieveCount = 12, + }, cancellationToken); + Assert.AreEqual(12, limited.Count); }, - cancellationToken); + graphQLClient => + { + async ValueTask TestPageSize(int? inputPageSize) + { + var outputPageSize = inputPageSize ?? ApiController.DefaultPageSize; - var skipped = await serverClient.Users.List(new PaginationSettings - { - Offset = 50, - RetrieveCount = 5 - }, cancellationToken); - Assert.AreEqual(5, skipped.Count); + string cursor = null; - var allAfterSkipped = await serverClient.Users.List(new PaginationSettings - { - Offset = 50, - }, cancellationToken); - Assert.IsTrue(5 < allAfterSkipped.Count); + var exactMatch = (expectedCount % outputPageSize) == 0; + var expectedIterations = (expectedCount / outputPageSize) + (exactMatch ? 0 : 1); + for (int i = 0; i < expectedIterations; ++i) + { + var isLastIteration = i == expectedIterations - 1; + var queryable = (await graphQLClient.RunQueryEnsureNoErrors( + gql => gql.PageUserIds.ExecuteAsync(inputPageSize, cursor, cancellationToken), + cancellationToken)) + .Swarm + .Users + .QueryableUsers; - var limited = await serverClient.Users.List(new PaginationSettings - { - RetrieveCount = 12, - }, cancellationToken); - Assert.AreEqual(12, limited.Count); + if (!isLastIteration || exactMatch) + Assert.AreEqual(outputPageSize, queryable.Nodes.Count); + else + Assert.AreEqual(expectedCount % outputPageSize, queryable.Nodes.Count); + Assert.AreEqual(!isLastIteration, queryable.PageInfo.HasNextPage); + Assert.IsNotNull(queryable.PageInfo.EndCursor); + + cursor = queryable.PageInfo.EndCursor; + } + } + + async ValueTask TestBadPageSize(int size) + { + var result = await graphQLClient.RunOperation( + gql => gql.PageUserIds.ExecuteAsync(size, null, cancellationToken), + cancellationToken); + + if (size == 0) + { + // special case + result.EnsureNoErrors(); + Assert.AreEqual(0, result.Data.Swarm.Users.QueryableUsers.Nodes.Count); + return; + } + + var errored = result.IsErrorResult(); + Assert.IsTrue(errored); + } + + return ValueTaskExtensions.WhenAll( + TestPageSize(null), + TestPageSize(ApiController.DefaultPageSize), + TestPageSize(ApiController.DefaultPageSize / 2), + TestPageSize(ApiController.MaximumPageSize), + TestPageSize(1), + TestBadPageSize(-1), + TestBadPageSize(0), + TestBadPageSize(ApiController.MaximumPageSize + 1), + TestBadPageSize(Int32.MinValue), + TestBadPageSize(Int32.MaxValue)); + }); } } } diff --git a/tests/Tgstation.Server.Tests/TestVersions.cs b/tests/Tgstation.Server.Tests/TestVersions.cs index 6da26320aa..a0809b78f6 100644 --- a/tests/Tgstation.Server.Tests/TestVersions.cs +++ b/tests/Tgstation.Server.Tests/TestVersions.cs @@ -32,7 +32,7 @@ using Tgstation.Server.Host.IO; using Tgstation.Server.Host.System; using Tgstation.Server.Api.Models; using Tgstation.Server.Tests.Live; -using Tgstation.Server.Host.Utils; +using Tgstation.Server.Host.Properties; namespace Tgstation.Server.Tests { @@ -75,14 +75,23 @@ namespace Tgstation.Server.Tests } [TestMethod] - public void TestApiVersion() + public void TestRestVersion() { - var versionString = versionsPropertyGroup.Element(xmlNamespace + "TgsApiVersion").Value; + var versionString = versionsPropertyGroup.Element(xmlNamespace + "TgsRestVersion").Value; Assert.IsNotNull(versionString); Assert.IsTrue(Version.TryParse(versionString, out var expected)); Assert.AreEqual(expected, ApiHeaders.Version); } + [TestMethod] + public void TestGraphQLVersion() + { + var versionString = versionsPropertyGroup.Element(xmlNamespace + "TgsGraphQLVersion").Value; + Assert.IsNotNull(versionString); + Assert.IsTrue(Version.TryParse(versionString, out var expected)); + Assert.AreEqual(expected, Version.Parse(MasterVersionsAttribute.Instance.RawGraphQLVersion)); + } + [TestMethod] public void TestApiLibraryVersion() { diff --git a/tgstation-server.sln b/tgstation-server.sln index d34815bd30..31d15d0159 100644 --- a/tgstation-server.sln +++ b/tgstation-server.sln @@ -219,6 +219,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "package", "package", "{2648 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deb", "deb", "{457A1F89-6201-4430-BCC6-2F4438A54B9E}" ProjectSection(SolutionItems) = preProject + build\package\deb\appsettings.GitHub.yml = build\package\deb\appsettings.GitHub.yml build\package\deb\build_package.sh = build\package\deb\build_package.sh build\package\deb\install_artifacts.sh = build\package\deb\install_artifacts.sh build\package\deb\MakeInstall = build\package\deb\MakeInstall @@ -244,6 +245,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "upstream", "upstream", "{B4 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "winget", "winget", "{506B9092-AF88-4DA2-84FD-C11646B695B0}" ProjectSection(SolutionItems) = preProject + build\package\winget\appsettings.GitHub.yml = build\package\winget\appsettings.GitHub.yml build\package\winget\install_banner.jpg = build\package\winget\install_banner.jpg build\package\winget\prepare_installer_input_artifacts.ps1 = build\package\winget\prepare_installer_input_artifacts.ps1 build\package\winget\push_manifest.ps1 = build\package\winget\push_manifest.ps1 diff --git a/tools/Tgstation.Server.ReleaseNotes/Component.cs b/tools/Tgstation.Server.ReleaseNotes/Component.cs index d1baa3fcda..ac2d1aa84b 100644 --- a/tools/Tgstation.Server.ReleaseNotes/Component.cs +++ b/tools/Tgstation.Server.ReleaseNotes/Component.cs @@ -1,4 +1,6 @@ -namespace Tgstation.Server.ReleaseNotes +using YamlDotNet.Serialization; + +namespace Tgstation.Server.ReleaseNotes { enum Component { @@ -6,7 +8,8 @@ Core, HostWatchdog, WebControlPanel, - HttpApi, + HttpApi, // can't be properly renamed due to changelog + GraphQLApi, DreamMakerApi, InteropApi, NugetCommon, diff --git a/tools/Tgstation.Server.ReleaseNotes/Program.cs b/tools/Tgstation.Server.ReleaseNotes/Program.cs index 801d10e779..3cceaf2237 100644 --- a/tools/Tgstation.Server.ReleaseNotes/Program.cs +++ b/tools/Tgstation.Server.ReleaseNotes/Program.cs @@ -91,9 +91,12 @@ namespace Tgstation.Server.ReleaseNotes case "--NO-CLOSE": doNotCloseMilestone = true; break; - case "--HTTPAPI": + case "--RESTAPI": componentRelease = Component.HttpApi; break; + case "--GRAPHQLAPI": + componentRelease = Component.GraphQLApi; + break; case "--INTEROPAPI": componentRelease = Component.InteropApi; break; @@ -245,7 +248,8 @@ namespace Tgstation.Server.ReleaseNotes return 10; } - var apiVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsApiVersion").Value); + var restVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsRestVersion").Value); + var graphQLVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsGraphQLVersion").Value); var configVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsConfigVersion").Value); var dmApiVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsDmapiVersion").Value); var interopVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsInteropVersion").Value); @@ -255,7 +259,7 @@ namespace Tgstation.Server.ReleaseNotes if (webControlVersion.Major == 0) postControlPanelMessage = true; - prefix = $"Please refer to the [README](https://github.com/tgstation/tgstation-server#setup) for setup instructions. Full changelog can be found [here](https://raw.githubusercontent.com/tgstation/tgstation-server/gh-pages/changelog.yml).{Environment.NewLine}{Environment.NewLine}#### Component Versions\nCore: {coreVersion}\nConfiguration: {configVersion}\nHTTP API: {apiVersion}\nDreamMaker API: {dmApiVersion} (Interop: {interopVersion})\n[Web Control Panel](https://github.com/tgstation/tgstation-server-webpanel): {webControlVersion}\nHost Watchdog: {hostWatchdogVersion}"; + prefix = $"Please refer to the [README](https://github.com/tgstation/tgstation-server#setup) for setup instructions. Full changelog can be found [here](https://raw.githubusercontent.com/tgstation/tgstation-server/gh-pages/changelog.yml).{Environment.NewLine}{Environment.NewLine}#### Component Versions\nCore: {coreVersion}\nConfiguration: {configVersion}\nREST API: {restVersion}\nGraphQL API{(graphQLVersion.Major < 1 ? " (Pre-release)" : String.Empty)}: {graphQLVersion}\nDreamMaker API: {dmApiVersion} (Interop: {interopVersion})\n[Web Control Panel](https://github.com/tgstation/tgstation-server-webpanel): {webControlVersion}\nHost Watchdog: {hostWatchdogVersion}"; var newNotes = new StringBuilder(prefix); if (postControlPanelMessage) @@ -409,7 +413,8 @@ namespace Tgstation.Server.ReleaseNotes var componentVersionDict = new Dictionary { { Component.Configuration, configVersion }, - { Component.HttpApi, apiVersion }, + { Component.HttpApi, restVersion }, + { Component.GraphQLApi, graphQLVersion }, { Component.DreamMakerApi, dmApiVersion }, { Component.InteropApi, interopVersion }, { Component.WebControlPanel, webControlVersion }, @@ -508,7 +513,8 @@ namespace Tgstation.Server.ReleaseNotes static string GetComponentDisplayName(Component component, bool debian) => component switch { - Component.HttpApi => debian ? "the HTTP API" : "HTTP API", + Component.HttpApi => debian ? "the REST API" : "REST API", + Component.GraphQLApi => debian ? "the GraphQL API" : "GraphQL API", Component.InteropApi => debian ? "the Interop API" : "Interop API", Component.Configuration => debian ? "the TGS configuration" : "**Configuration**", Component.DreamMakerApi => debian ? "the DreamMaker API" : "DreamMaker API", @@ -607,7 +613,8 @@ namespace Tgstation.Server.ReleaseNotes var dict = new Dictionary { { Component.Core, Parse("TgsCoreVersion") }, - { Component.HttpApi, Parse("TgsApiVersion") }, + { Component.HttpApi, Parse("TgsRestVersion") }, + { Component.GraphQLApi, Parse("TgsGraphQLVersion") }, { Component.DreamMakerApi, Parse("TgsDmapiVersion") }, }; @@ -720,7 +727,8 @@ namespace Tgstation.Server.ReleaseNotes component = targetComponent.ToUpperInvariant() switch { "**CONFIGURATION**" or "CONFIGURATION" or "CONFIG" => Component.Configuration, - "HTTP API" => Component.HttpApi, + "HTTP API" or "REST API" => Component.HttpApi, + "GQL API" or "GRAPHQL API" or "GQL" or "GRAPHQL" => Component.GraphQLApi, "WEB CONTROL PANEL" => Component.WebControlPanel, "DMAPI" or "DREAMMAKER API" => Component.DreamMakerApi, "INTEROP API" => Component.InteropApi, @@ -1160,15 +1168,23 @@ Note: `` is the directory's name containing the manifest you're submitting var nugetApiVersions = EnumerateNugetVersions("Tgstation.Server.Api"); var nugetClientVersions = EnumerateNugetVersions("Tgstation.Server.Client"); + const string ApiTagPrefix = "api-v"; + const string GraphQLTagPrefix = "graphql-v"; + const string DMApiTagPrefix = "dmapi-v"; var newDic = new Dictionary> { { Component.HttpApi, releases - .Where(x => x.TagName.StartsWith("api-v")) - .Select(x => Version.Parse(x.TagName[5..])) + .Where(x => x.TagName.StartsWith(ApiTagPrefix)) + .Select(x => Version.Parse(x.TagName[ApiTagPrefix.Length..])) + .OrderBy(x => x) + .ToHashSet() }, + { Component.GraphQLApi, releases + .Where(x => x.TagName.StartsWith(GraphQLTagPrefix)) + .Select(x => Version.Parse(x.TagName[GraphQLTagPrefix.Length..])) .OrderBy(x => x) .ToHashSet() }, { Component.DreamMakerApi, releases - .Where(x => x.TagName.StartsWith("dmapi-v")) - .Select(x => Version.Parse(x.TagName[7..])) + .Where(x => x.TagName.StartsWith(DMApiTagPrefix)) + .Select(x => Version.Parse(x.TagName[DMApiTagPrefix.Length..])) .OrderBy(x => x) .ToHashSet() }, { Component.NugetCommon, await nugetCommonVersions }, @@ -1480,10 +1496,10 @@ package (version) distribution(s); urgency=urgency || component == Component.NugetCommon) continue; - var takeNotesFrom = previousRelease.ComponentVersions[componentKvp.Key]; + var hasPreviousRelease = previousRelease.ComponentVersions.TryGetValue(componentKvp.Key, out var takeNotesFrom); var changesEnumerator = releaseNotes .Components[component] - .Where(changelist => changelist.Version > takeNotesFrom && changelist.Version <= componentKvp.Value) + .Where(changelist => !hasPreviousRelease || (changelist.Version > takeNotesFrom && changelist.Version <= componentKvp.Value)) .SelectMany(x => x.Changes) .OrderBy(x => x.PullRequest); var changelist = new Changelist