Merge pull request #2409 from tgstation/LetsGetThisOut [DMDeploy][TGSDeploy]

v6.19.0: FUCK
This commit is contained in:
Jordan Dominion
2026-01-11 23:19:15 -05:00
committed by GitHub
332 changed files with 6859 additions and 8655 deletions
@@ -14,6 +14,7 @@ concurrency:
jobs:
fail-on-bad-milestone:
if: github.event.pull_request.draft != true
name: Fail if Pull Request has no Associated Version Milestone
runs-on: ubuntu-latest
steps:
+111 -267
View File
@@ -54,6 +54,7 @@ jobs:
start-gate:
name: CI Start Gate
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- name: GitHub Requires at Least One Step for a Job
run: exit 0
@@ -62,6 +63,7 @@ jobs:
name: Build ReleaseNotes for Other Jobs
needs: start-gate
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Install Native Dependencies
run: |
@@ -97,8 +99,7 @@ jobs:
name: Validate Nix Flake
needs: start-gate
runs-on: ubuntu-latest
env:
TEST_TGS_VERSION: "6.17.0" # Version we use here doesn't matter as it won't be executed. Just used to download a zip and calc hash
timeout-minutes: 90
steps:
- name: Install Native Packages # Name checked in rerunFlakyTests.js
run: |
@@ -120,24 +121,6 @@ jobs:
with:
ref: "refs/pull/${{ inputs.pull_request_number }}/merge"
- name: Replace current TGS version with test version
run: |
CURRENT_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)"
sed -i -e "s/<TgsCoreVersion>${CURRENT_TGS_VERSION}/<TgsCoreVersion>${{ env.TEST_TGS_VERSION }}/g" build/Version.props
- name: Retrieve ServerConsole.zip Artifact
run: |
mkdir release
curl -L https://github.com/tgstation/tgstation-server/releases/download/tgstation-server-v${{ env.TEST_TGS_VERSION }}/ServerConsole.zip -f -o release/ServerConsole.zip
- name: Regenerate Nix Hash
run: |
nix hash path ./release > build/package/nix/ServerConsole.sha256
cat build/package/nix/ServerConsole.sha256
- name: Cleanup Download
run: rm -rf ./release
- name: Check Flake
run: |
cd build/package/nix
@@ -147,10 +130,9 @@ jobs:
name: Run CodeQL
needs: start-gate
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
security-events: write
env:
TGS_TELEMETRY_KEY_FILE: /tmp/tgs_telemetry_key.txt
steps:
- name: Install Native Dependencies
run: |
@@ -192,9 +174,6 @@ jobs:
with:
languages: csharp
- name: Setup Telemetry Key File
run: echo "fake_telemetry_key" > ${{ env.TGS_TELEMETRY_KEY_FILE }}
- name: Build # Name checked in rerunFlakyTests.js
run: dotnet build -c ReleaseNoWindows -p:TGS_HOST_NO_WEBPANEL=true
@@ -206,6 +185,7 @@ jobs:
dmapi-build:
name: Build DMAPI
needs: start-gate
timeout-minutes: 5
strategy:
fail-fast: false
matrix:
@@ -305,6 +285,7 @@ jobs:
opendream-build:
name: Build DMAPI (OpenDream)
needs: start-gate
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
@@ -362,6 +343,7 @@ jobs:
name: Check Nuget Versions Match Tools
runs-on: ubuntu-latest
needs: start-gate
timeout-minutes: 1
steps:
- name: Checkout (Branch)
uses: actions/checkout@v4
@@ -381,11 +363,19 @@ jobs:
id: wix-tool
run: echo "version=$(cat build/package/winget/.config/dotnet-tools.json | jq -r '.tools.wix.version')" >> $GITHUB_OUTPUT
- name: Retrieve StrawberryShake Tool Version 1
id: strawberry-tool-1
run: echo "version=$(cat src/Tgstation.Server.Host.Utils.GitLab.GraphQL/.config/dotnet-tools.json | jq -r '.tools."strawberryshake.tools".version')" >> $GITHUB_OUTPUT
- name: Retrieve StrawberryShake Tool Version 2
id: strawberry-tool-2
run: echo "version=$(cat src/Tgstation.Server.Client.GraphQL/.config/dotnet-tools.json | jq -r '.tools."strawberryshake.tools".version')" >> $GITHUB_OUTPUT
- name: Retrieve dotnet-ef Nuget Version
id: dotnet-ef-nuget
run: |
regex='\s+<PackageReference Include="Microsoft.EntityFrameworkCore" Version="([1-9][0-9]*\.[0-9]+\.[0-9]+)" \/>'
if [[ $(cat src/Tgstation.Server.Host/Tgstation.Server.Host.csproj) =~ $regex ]]; then
regex='\s+<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="([1-9][0-9]*\.[0-9]+\.[0-9]+)" \/>'
if [[ $(cat Directory.Packages.props) =~ $regex ]]; then
echo "version=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
else
echo "Regex search failed!"
@@ -403,6 +393,17 @@ jobs:
exit 1
fi
- name: Retrieve StrawberryShake Nuget Version
id: strawberry-nuget
run: |
regex='\s+<PackageVersion Include="StrawberryShake.Server" Version="([1-9][0-9]*\.[0-9]+\.[0-9]+)" \/>'
if [[ $(cat Directory.Packages.props) =~ $regex ]]; then
echo "version=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
else
echo "Regex search failed!"
exit 1
fi
- name: Fail if dotnet-ef Versions Don't Match
if: ${{ steps.dotnet-ef-tool.outputs.version != steps.dotnet-ef-nuget.outputs.version }}
run: |
@@ -415,10 +416,17 @@ jobs:
echo "${{ steps.wix-tool.outputs.version }} != ${{ steps.wix-nuget.outputs.version }}"
exit 1
- name: Fail if StrawberryShake Versions Don't Match
if: ${{ steps.strawberry-tool-1.outputs.version != steps.strawberry-nuget.outputs.version || steps.strawberry-tool-2.outputs.version != steps.strawberry-nuget.outputs.version }}
run: |
echo "${{ steps.strawberry-tool-1.outputs.version }} != ${{ steps.strawberry-nuget.outputs.version }} || ${{ steps.strawberry-tool-2.outputs.version }} != ${{ steps.strawberry-nuget.outputs.version }}"
exit 1
pages-build:
name: Build gh-pages
needs: build-releasenotes
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
@@ -497,8 +505,7 @@ jobs:
name: Build Docker Image
runs-on: ubuntu-latest
needs: start-gate
env:
TGS_TELEMETRY_KEY_FILE: tgs_telemetry_key.txt
timeout-minutes: 10
steps:
- name: Checkout (Branch)
uses: actions/checkout@v4
@@ -510,26 +517,17 @@ jobs:
with:
ref: "refs/pull/${{ inputs.pull_request_number }}/merge"
- name: Setup Telemetry Key File
shell: bash
run: echo "${{ secrets.TGS_TELEMETRY_KEY }}" > ${{ env.TGS_TELEMETRY_KEY_FILE }}
- name: Build Docker Image # Name checked in rerunFlakyTests.js
run: docker build . -f build/Dockerfile --build-arg TGS_TELEMETRY_KEY_FILE=${{ env.TGS_TELEMETRY_KEY_FILE }}
- name: Delete Telemetry Key File
if: always()
run: rm -f ${{ env.TGS_TELEMETRY_KEY_FILE }}
run: docker build . -f build/Dockerfile
linux-unit-tests:
name: Linux Tests
needs: start-gate
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
configuration: ["Debug", "Release"]
env:
TGS_TELEMETRY_KEY_FILE: /tmp/tgs_telemetry_key.txt
runs-on: ubuntu-latest
steps:
- name: Install Native x86 libc Dependencies # Name checked in rerunFlakyTests.js
@@ -562,16 +560,9 @@ jobs:
- name: Enable Corepack
run: corepack enable
- name: Setup Telemetry Key File
run: echo "${{ secrets.TGS_TELEMETRY_KEY }}" > ${{ env.TGS_TELEMETRY_KEY_FILE }}
- name: Build # Name checked in rerunFlakyTests.js
run: dotnet build -c ${{ matrix.configuration }}NoWindows
- name: Delete Telemetry Key File
if: always()
run: rm -f ${{ env.TGS_TELEMETRY_KEY_FILE }}
- name: Run Unit Tests
run: sudo dotnet test --no-build --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" --filter TestCategory!=RequiresDatabase -c ${{ matrix.configuration }}NoWindows --collect:"XPlat Code Coverage" --settings build/ci.runsettings --results-directory ./TestResults tgstation-server.sln
env:
@@ -590,12 +581,11 @@ jobs:
windows-unit-tests:
name: Windows Tests
needs: start-gate
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
configuration: ["Debug", "Release"]
env:
TGS_TELEMETRY_KEY_FILE: C:/tgs_telemetry_key.txt
runs-on: windows-2025
steps:
- name: Setup dotnet
@@ -622,18 +612,9 @@ jobs:
- name: Enable Corepack
run: corepack enable
- name: Setup Telemetry Key File
shell: bash
run: echo "${{ secrets.TGS_TELEMETRY_KEY }}" > ${{ env.TGS_TELEMETRY_KEY_FILE }}
- name: Build # Name checked in rerunFlakyTests.js
run: dotnet build -c ${{ matrix.configuration }}NoWix
- name: Delete Telemetry Key File
shell: bash
if: always()
run: rm -f ${{ env.TGS_TELEMETRY_KEY_FILE }}
- name: Run Unit Tests
run: dotnet test --no-build --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" --filter TestCategory!=RequiresDatabase -c ${{ matrix.configuration }}NoWix --collect:"XPlat Code Coverage" --settings build/ci.runsettings --results-directory ./TestResults tgstation-server.sln
env:
@@ -651,6 +632,7 @@ jobs:
name: Prepare Live Tests Cache of EDGE Versions
needs: start-gate
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- name: Cache BYOND .zips (Linux)
uses: actions/cache@v4
@@ -691,6 +673,7 @@ jobs:
windows-integration-tests:
name: Windows Live Tests
needs: [dmapi-build, opendream-build, prep-edge-versions]
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
@@ -698,8 +681,6 @@ jobs:
["SqlServer", "Sqlite", "PostgresSql", "MariaDB", "MySql"]
watchdog-type: ["Basic", "Advanced"]
configuration: ["Debug", "Release"]
env:
TGS_TELEMETRY_KEY_FILE: C:/tgs_telemetry_key.txt
runs-on: windows-2025
steps:
- name: Setup dotnet
@@ -801,18 +782,9 @@ jobs:
- name: Enable Corepack
run: corepack enable
- name: Setup Telemetry Key File
shell: bash
run: echo "${{ secrets.TGS_TELEMETRY_KEY }}" > ${{ env.TGS_TELEMETRY_KEY_FILE }}
- name: Build # Name checked in rerunFlakyTests.js
run: dotnet build -c ${{ matrix.configuration }} tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj
- name: Delete Telemetry Key File
shell: bash
if: always()
run: rm -f ${{ env.TGS_TELEMETRY_KEY_FILE }}
- name: Run Live Tests # Logging here is weird because printing massive amounts of text on Windows runners is SLOW AS SHIT!!!
id: live-tests
shell: bash
@@ -906,6 +878,7 @@ jobs:
linux-integration-tests:
name: Linux Live Tests
needs: [dmapi-build, opendream-build, prep-edge-versions]
timeout-minutes: 60
services: # We start all dbs here so we can just code the stuff once
mssql:
image: ${{ (matrix.database-type == 'SqlServer') && 'mcr.microsoft.com/mssql/server:2019-latest' || '' }}
@@ -953,8 +926,6 @@ jobs:
database-type: ["Sqlite", "PostgresSql", "MariaDB", "MySql"]
watchdog-type: ["Basic", "Advanced"]
configuration: ["Debug", "Release"]
env:
TGS_TELEMETRY_KEY_FILE: /tmp/tgs_telemetry_key.txt
runs-on: ubuntu-latest
steps:
- name: Setup dotnet
@@ -1030,16 +1001,9 @@ jobs:
- name: Enable Corepack
run: corepack enable
- name: Setup Telemetry Key File
run: echo "${{ secrets.TGS_TELEMETRY_KEY }}" > ${{ env.TGS_TELEMETRY_KEY_FILE }}
- name: Build # Name checked in rerunFlakyTests.js
run: dotnet build -c ${{ matrix.configuration }}NoWindows tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj
- name: Delete Telemetry Key File
if: always()
run: rm -f ${{ env.TGS_TELEMETRY_KEY_FILE }}
- name: Run Live Tests
run: |
cd tests/Tgstation.Server.Tests
@@ -1091,6 +1055,7 @@ jobs:
name: OpenAPI Spec Validation
needs: windows-integration-tests
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Install IBM OpenAPI Validator
run: npm i -g ibm-openapi-validator@0.51.3
@@ -1116,6 +1081,7 @@ jobs:
upload-code-coverage:
name: Upload Code Coverage
timeout-minutes: 5
needs:
[
linux-unit-tests,
@@ -1382,8 +1348,7 @@ jobs:
name: Build .deb Package # Can't do i386 due to https://github.com/dotnet/core/issues/4595
needs: build-releasenotes
runs-on: ubuntu-latest
env:
TGS_TELEMETRY_KEY_FILE: /tmp/tgs_telemetry_key.txt
timeout-minutes: 30
steps:
- name: Install Native Dependencies # Name checked in rerunFlakyTests.js
run: |
@@ -1430,9 +1395,6 @@ jobs:
- name: Grab Most Recent Changelog
run: curl -L https://raw.githubusercontent.com/tgstation/tgstation-server/gh-pages/changelog.yml -o changelog.yml
- name: Setup Telemetry Key File
run: echo "${{ secrets.TGS_TELEMETRY_KEY }}" > ${{ env.TGS_TELEMETRY_KEY_FILE }}
- name: Retrieve ReleaseNotes Binaries
uses: actions/download-artifact@v4
with:
@@ -1472,10 +1434,6 @@ jobs:
gpg --verify tgstation-server_${{ env.TGS_VERSION }}-1_amd64.changes
gpg --verify tgstation-server_${{ env.TGS_VERSION }}-1_amd64.buildinfo
- name: Delete Telemetry Key File
if: always()
run: rm -f ${{ env.TGS_TELEMETRY_KEY_FILE }}
- name: Test Install
run: |
sudo mkdir /etc/tgstation-server
@@ -1539,8 +1497,7 @@ jobs:
name: Build Windows Installer .exe
runs-on: windows-2025
needs: start-gate
env:
TGS_TELEMETRY_KEY_FILE: C:/tgs_telemetry_key.txt
timeout-minutes: 15
steps:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
@@ -1574,18 +1531,9 @@ jobs:
- name: Enable Corepack
run: corepack enable
- name: Setup Telemetry Key File
shell: bash
run: echo "${{ secrets.TGS_TELEMETRY_KEY }}" > ${{ env.TGS_TELEMETRY_KEY_FILE }}
- name: Build Host # Name checked in rerunFlakyTests.js
run: dotnet build -c Release src/Tgstation.Server.Host/Tgstation.Server.Host.csproj
- name: Delete Telemetry Key File
shell: bash
if: always()
run: rm -f ${{ env.TGS_TELEMETRY_KEY_FILE }}
- name: Build Service # Name checked in rerunFlakyTests.js
run: dotnet build -c Release src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj
@@ -1684,6 +1632,7 @@ jobs:
name: Check winget-pkgs Pull Request Template is up to date
needs: build-releasenotes
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
@@ -1734,6 +1683,7 @@ jobs:
ci-completion-gate:
if: always() && !cancelled()
name: CI Completion Gate
timeout-minutes: 1
needs:
[
pages-build,
@@ -1823,26 +1773,22 @@ jobs:
TGS_RELEASE_NOTES_TOKEN: ${{ steps.app-token-generation.outputs.token }}
run: dotnet release_notes_bins/Tgstation.Server.ReleaseNotes.dll ${{ env.TGS_API_VERSION }} --restapi
- name: Create GitHub Release
uses: actions/create-release@v1
id: create_release
env:
GITHUB_TOKEN: ${{ steps.app-token-generation.outputs.token }}
with:
tag_name: 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 }}
- name: Setup Release Artifacts
run: |
mkdir release_assets
cp ./swaggger/tgs_api.json ./release_assets/swagger.json
- name: Upload OpenApi Spec
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ steps.app-token-generation.outputs.token }}
- name: Create GitHub Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./swagger/tgs_api.json
asset_name: swagger.json
asset_content_type: application/json
token: ${{ steps.app-token-generation.outputs.token }}
tag_name: api-v${{ env.TGS_API_VERSION }}
files: |
./release_assets/*
name: tgstation-server REST API v${{ env.TGS_API_VERSION }}
body_path: release_notes.md
target_commitish: ${{ github.event.head_commit.id }}
make_latest: false
deploy-gql:
name: Deploy GraphQL API
@@ -1910,27 +1856,23 @@ jobs:
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: Setup Release Artifacts
run: |
mkdir release_assets
cp ./schema/tgs-api.graphql ./release_assets/tgs-api.graphql
- name: Upload GraphQL Schema
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ steps.app-token-generation.outputs.token }}
- name: Create GitHub Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b
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
token: ${{ steps.app-token-generation.outputs.token }}
tag_name: graphql-v${{ env.TGS_API_VERSION }}
files: |
./release_assets/*
name: tgstation-server GraphQL API v${{ env.TGS_API_VERSION }}
body_path: release_notes.md
target_commitish: ${{ github.event.head_commit.id }}
make_latest: false
prerelease: ${{ env.TGS_GRAPHQL_PRERELEASE }}
deploy-dm:
name: Deploy DreamMaker API
@@ -1991,26 +1933,22 @@ jobs:
TGS_RELEASE_NOTES_TOKEN: ${{ steps.app-token-generation.outputs.token }}
run: dotnet release_notes_bins/Tgstation.Server.ReleaseNotes.dll ${{ env.TGS_DM_VERSION }} --dmapi
- name: Create GitHub Release
uses: actions/create-release@v1
id: create_release
env:
GITHUB_TOKEN: ${{ steps.app-token-generation.outputs.token }}
with:
tag_name: dmapi-v${{ env.TGS_DM_VERSION }}
release_name: tgstation-server DMAPI v${{ env.TGS_DM_VERSION }}
body_path: release_notes.md
commitish: ${{ github.event.head_commit.id }}
- name: Setup Release Artifacts
run: |
mkdir release_assets
cp ./DMAPI.zip ./release_assets/DMAPI.zip
- name: Upload DMAPI Artifact
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ steps.app-token-generation.outputs.token }}
- name: Create GitHub Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./DMAPI.zip
asset_name: DMAPI.zip
asset_content_type: application/zip
token: ${{ steps.app-token-generation.outputs.token }}
tag_name: dmapi-v${{ env.TGS_DM_VERSION }}
files: |
./release_assets/*
name: tgstation-server DMAPI v${{ env.TGS_DM_VERSION }}
body_path: release_notes.md
target_commitish: ${{ github.event.head_commit.id }}
make_latest: false
deploy-client:
name: Deploy Nuget Packages
@@ -2107,8 +2045,6 @@ jobs:
needs: [deploy-dm, deploy-rest, deploy-gql, deployment-gate]
runs-on: windows-2025
if: (!(cancelled() || failure())) && github.event.ref == 'refs/heads/master' && contains(github.event.head_commit.message, '[TGSDeploy]') && needs.deployment-gate.result == 'success'
env:
TGS_TELEMETRY_KEY_FILE: C:/tgs_telemetry_key.txt
permissions:
id-token: write
attestations: write
@@ -2135,19 +2071,10 @@ jobs:
- name: Enable Corepack
run: corepack enable
- name: Setup Telemetry Key File
shell: bash
run: echo "${{ secrets.TGS_TELEMETRY_KEY }}" > ${{ env.TGS_TELEMETRY_KEY_FILE }}
# We need to rebuild the installer.exe so it can be properly signed
- name: Build Host # Name checked in rerunFlakyTests.js
run: dotnet build -c Release src/Tgstation.Server.Host/Tgstation.Server.Host.csproj
- name: Delete Telemetry Key File
shell: bash
if: always()
run: rm -f ${{ env.TGS_TELEMETRY_KEY_FILE }}
- name: Build Service # Name checked in rerunFlakyTests.js
run: dotnet build -c Release src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj
@@ -2310,113 +2237,30 @@ jobs:
TGS_RELEASE_NOTES_TOKEN: ${{ steps.app-token-generation.outputs.token }}
run: dotnet release_notes_bins/Tgstation.Server.ReleaseNotes.dll ${{ env.TGS_VERSION }}
- name: Setup Release Artifacts
run: |
mkdir release_assets
cp ./ServerConsole.zip ./release_assets/ServerConsole.zip
cp ./ServerService.zip ./release_assets/ServerService.zip
cp ./DMAPI.zip ./release_assets/DMAPI.zip
cp ./swagger/tgs_api.json ./release_assets/swagger.json
cp ./schema/tgs-api.graphql ./release_assets/tgs-api.graphql
cp ./ServerUpdatePackage.zip ./release_assets/ServerUpdatePackage.zip
cp ./packaging-debian/tgstation-server-v${{ env.TGS_VERSION }}.debian.packaging.tar.xz ./release_assets/tgstation-server-v${{ env.TGS_VERSION }}.debian.packaging.tar.xz
cp ./build/package/winget/Tgstation.Server.Host.Service.Wix.Bundle/bin/Release/mariadb.msi ./release_assets/mariadb-${{ env.MARIADB_VERSION }}-winx64.msi
cp ./build/package/winget/tgstation-server-installer.exe ./release_assets/tgstation-server-installer.exe
- name: Create GitHub Release
uses: actions/create-release@v1
id: create_release
env:
GITHUB_TOKEN: ${{ steps.app-token-generation.outputs.token }}
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b
with:
token: ${{ steps.app-token-generation.outputs.token }}
tag_name: tgstation-server-v${{ env.TGS_VERSION }}
release_name: tgstation-server-v${{ env.TGS_VERSION }}
files: |
./release_assets/*
name: tgstation-server-v${{ env.TGS_VERSION }}
body_path: release_notes.md
commitish: ${{ github.event.head_commit.id }}
- name: Upload Server Console Artifact to Release
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: ./ServerConsole.zip
asset_name: ServerConsole.zip
asset_content_type: application/zip
- name: Upload Server Service 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: ./ServerService.zip
asset_name: ServerService.zip
asset_content_type: application/zip
- name: Upload DMAPI 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: ./DMAPI.zip
asset_name: DMAPI.zip
asset_content_type: application/zip
- name: Upload REST 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: ./swagger/tgs_api.json
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:
GITHUB_TOKEN: ${{ steps.app-token-generation.outputs.token }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./ServerUpdatePackage.zip
asset_name: ServerUpdatePackage.zip
asset_content_type: application/zip
- name: Upload Debian Packaging 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: ./packaging-debian/tgstation-server-v${{ env.TGS_VERSION }}.debian.packaging.tar.xz
asset_name: tgstation-server-v${{ env.TGS_VERSION }}.debian.packaging.tar.xz
asset_content_type: application/x-tar
- name: Upload MariaDB .msi
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: ./build/package/winget/Tgstation.Server.Host.Service.Wix.Bundle/bin/Release/mariadb.msi
asset_name: mariadb-${{ env.MARIADB_VERSION }}-winx64.msi
asset_content_type: application/octet-stream
- name: Upload Installer .exe
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: ./build/package/winget/tgstation-server-installer.exe
asset_name: tgstation-server-installer.exe
asset_content_type: application/octet-stream
update-nix:
name: Nix Deployment
needs: changelog-regen
if: (!(cancelled() || failure())) && needs.changelog-regen.result == 'success'
uses: ./.github/workflows/nix-deployment.yml
secrets: inherit
target_commitish: ${{ github.event.head_commit.id }}
make_latest: true
changelog-regen:
name: Regenerate Changelog
+2 -2
View File
@@ -24,7 +24,7 @@ concurrency:
jobs:
security-checkpoint:
name: Check CI Clearance
if: github.event_name == 'pull_request_target' && (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id || github.event.pull_request.user.id == 49699333) && github.event.pull_request.state == 'open'
if: github.event_name == 'pull_request_target' && (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id || github.event.pull_request.user.id == 49699333) && github.event.pull_request.state == 'open' && github.event.pull_request.draft != true
runs-on: ubuntu-latest
steps:
- name: Generate App Token
@@ -62,7 +62,7 @@ jobs:
ci-pipline-workflow-call:
name: CI Pipeline
needs: security-checkpoint
if: (!(cancelled() || failure()) && (needs.security-checkpoint.result == 'success' || (github.event_name != 'pull_request_target' && github.event.pull_request.head.repo.id == github.event.pull_request.base.repo.id && github.event.pull_request.user.id != 49699333)))
if: (!(cancelled() || failure()) && (needs.security-checkpoint.result == 'success' || (github.event_name != 'pull_request_target' && github.event.pull_request.head.repo.id == github.event.pull_request.base.repo.id && github.event.pull_request.user.id != 49699333)) && github.event.pull_request.draft != true)
uses: ./.github/workflows/ci-pipeline.yml
secrets: inherit
with:
-68
View File
@@ -1,68 +0,0 @@
name: Nix Deployment
on:
workflow_call:
workflow_dispatch:
jobs:
update-nix:
name: Update Nix SHA (master)
runs-on: ubuntu-latest
steps:
- name: Install Native Packages # Name checked in rerunFlakyTests.js
run: |
sudo apt-get update
sudo apt-get install -y xmlstarlet
- name: Setup Nix
uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- 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: Checkout
uses: actions/checkout@v4
with:
token: ${{ steps.app-token-generation.outputs.token }}
ref: master
fetch-depth: 0
# fetch-tags: true Doesn't work, see https://github.com/actions/checkout/issues/1471
- name: Parse TGS version
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: Retrieve ServerConsole.zip Artifact
run: |
mkdir release
curl -L https://github.com/tgstation/tgstation-server/releases/download/tgstation-server-v${{ env.TGS_VERSION }}/ServerConsole.zip -f -o release/ServerConsole.zip
- name: Regenerate Nix Hash
run: |
nix hash path ./release > build/package/nix/ServerConsole.sha256
cat build/package/nix/ServerConsole.sha256
rm -rf ./release
- name: Commit
run: |
git config --global push.default simple
git config user.name "tgstation-server-ci[bot]"
git config user.email "161980869+tgstation-server-ci[bot]@users.noreply.github.com"
git add build/package/nix/ServerConsole.sha256
git commit -m "Update nix SHA256 for TGS v${{ env.TGS_VERSION }}"
- name: Re-tag
run: |
git tag -d tgstation-server-v${{ env.TGS_VERSION }}
git tag -a tgstation-server-v${{ env.TGS_VERSION }} -m tgstation-server-v${{ env.TGS_VERSION }}
- name: Push Commit
run: git push
- name: Force Push Tags
run: git push -f origin tag tgstation-server-v${{ env.TGS_VERSION }}
+54
View File
@@ -0,0 +1,54 @@
name: Regenerate Nix Nuget Dependencies
on:
pull_request_target:
paths:
- '**.csproj'
- '**.props'
- '**.yml'
branches:
- dev
- master
- V7
concurrency:
group: "regenerate-nix-dependencies-${{ github.head_ref || github.run_id }}-${{ github.event_name }}"
cancel-in-progress: true
jobs:
regenerate-nix-dependencies:
name: Regenerate Nix Dependencies
runs-on: ubuntu-latest
if: github.event.pull_request.head.repo.id == github.event.pull_request.base.repo.id
steps:
- name: Setup Nix
uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- 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: Clone PR Branch
run: git clone -b ${{ github.event.pull_request.head.ref }} --depth 1 "https://tgstation-server-ci:${{ steps.app-token-generation.outputs.token }}@github.com/tgstation/tgstation-server" .
- name: Regenerate Dependencies
run: |
cd build/package/nix
nix shell nixpkgs#nuget-to-json nixpkgs#dotnetCorePackages.sdk_8_0 -c "./GenDeps.sh"
- name: Commit
run: |
git config user.name "tgstation-server-ci[bot]"
git config user.email "161980869+tgstation-server-ci[bot]@users.noreply.github.com"
git add build/package/nix/deps.json
git diff-index --quiet HEAD || git commit -m "Regenerate Nix Hashes based on Nuget Packages"
- name: Push
run: |
git config --global push.default simple
git push -u origin ${{ github.event.pull_request.head.ref }} 2>&1
+83
View File
@@ -0,0 +1,83 @@
<Project>
<ItemGroup>
<PackageVersion Include="Byond.TopicSender" Version="8.0.1" />
<PackageVersion Include="Core.System.ServiceProcess" Version="2.0.1" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Cyberboss.AspNetCore.AsyncInitializer" Version="1.2.0" />
<PackageVersion Include="Cyberboss.SmartIrc4net.Standard" Version="1.0.0" />
<PackageVersion Include="DotEnv.Core" Version="3.1.0" />
<PackageVersion Include="Elastic.CommonSchema.Serilog" Version="9.0.0" />
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" />
<PackageVersion Include="HotChocolate.AspNetCore" Version="15.1.9" />
<PackageVersion Include="HotChocolate.AspNetCore.Authorization" Version="15.1.9" />
<PackageVersion Include="HotChocolate.Data.EntityFramework" Version="15.1.9" />
<PackageVersion Include="HotChocolate.Types.Analyzers" Version="15.1.9" />
<PackageVersion Include="HotChocolate.Types.Scalars" Version="15.1.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="LibGit2Sharp" Version="0.31.0" />
<PackageVersion Include="McMaster.Extensions.CommandLineUtils" Version="4.1.1" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.8" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.19" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.19" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Extensions" Version="2.3.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.19" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="9.0.8" />
<PackageVersion Include="Microsoft.Diagnostics.NETCore.Client" Version="0.2.621003" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.7" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.19" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging.EventLog" Version="9.0.8" />
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.14.0" />
<PackageVersion Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
<PackageVersion Include="MSTest.TestAdapter" Version="3.10.3" />
<PackageVersion Include="MSTest.TestFramework" Version="3.10.3" />
<PackageVersion Include="Mono.Posix.NETStandard" Version="1.0.0" />
<!-- Pinned: Be VERY careful about updating https://github.com/moq/moq/issues/1372 -->
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="NCrontab.Signed" Version="3.3.3" />
<PackageVersion Include="NetEscapades.Configuration.Yaml" Version="3.1.0" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageVersion Include="Octokit" Version="14.0.0" />
<PackageVersion Include="Octokit.GraphQL" Version="0.3.0-beta" />
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageVersion Include="prometheus-net.AspNetCore.HealthChecks" Version="8.2.1" />
<PackageVersion Include="Remora.Discord" Version="2025.2.0" />
<PackageVersion Include="Serilog.Extensions.Logging" Version="9.0.2" />
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageVersion Include="Serilog.Sinks.Elasticsearch" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="StrawberryShake.Server" Version="15.1.9" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="Svg" Version="3.4.7" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="9.0.4" />
<PackageVersion Include="Swashbuckle.AspNetCore.Newtonsoft" Version="9.0.4" />
<PackageVersion Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageVersion Include="System.DirectoryServices.AccountManagement" Version="9.0.8" />
<PackageVersion Include="System.Drawing.Common" Version="9.0.8" />
<PackageVersion Include="System.IO.Abstractions" Version="22.0.15" />
<PackageVersion Include="System.IO.Abstractions.TestingHelpers" Version="22.0.15" />
<PackageVersion Include="System.Management" Version="9.0.8" />
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
<PackageVersion Include="System.Runtime.InteropServices" Version="4.3.0" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="9.0.8" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageVersion Include="WixToolset.Bal.wixext" Version="5.0.2" />
<PackageVersion Include="WixToolset.Dtf.CustomAction" Version="5.0.2" />
<PackageVersion Include="WixToolset.Heat" Version="5.0.2" />
<PackageVersion Include="WixToolset.Netfx.wixext" Version="5.0.2" />
<PackageVersion Include="WixToolset.Util.wixext" Version="5.0.2" />
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
</Project>
+1 -6
View File
@@ -169,6 +169,7 @@ The following dependencies are required.
- aspnetcore-runtime-8.0 (See Prerequisites under the `Ubuntu/Debian Package` section)
- libc6-i386
- libstdc++6:i386
- libcurl4:i386
- gcc-multilib (Only on 64-bit systems)
- gdb (for using gcore to create core dumps)
@@ -321,12 +322,6 @@ Create an `appsettings.Production.yml` file next to `appsettings.yml`. This will
- `Swarm:UpdateRequiredNodeCount`: Should be set to the total number of servers in your swarm minus 1. Prevents updates from occurring unless the non-controller server count in the swarm is greater than or equal to this value.
- `Telemetry:DisableVersionReporting`: Prevents you installation and the version you're using from being reported on the source repository's deployments list
- `Telemetry:ServerFriendlyName`: Prevents anonymous TGS version usage statistics from being sent to be displayed on the repository.
- `Telemetry:VersionReportingRepositoryId`: The repository telemetry is sent to. For security reasons, this is not the main TGS repo. See the [tgstation-server-deployments](https://github.com/tgstation/tgstation-server-deployments) repository for more information.
#### OAuth Configuration
- `Security:OAuth:<Provider Name>`: Sets the OAuth client ID and secret for a given `<Provider Name>`. The currently supported providers are `GitHub`, `Discord`, and `InvisionCommunity`. Setting these fields to `null` disables logins AND gateway auth with the provider, but does not stop users from associating their accounts using the API. Sample Entry:
+1
View File
@@ -5,6 +5,7 @@
<TgsFrameworkVersion>net$(TgsNetMajorVersion).0</TgsFrameworkVersion>
<LangVersion>latest</LangVersion>
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<!-- This is the default but Wix hates it -->
<!--<DebugType>Portable</DebugType> -->
</PropertyGroup>
+8 -12
View File
@@ -1,17 +1,14 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim AS build
# Set in CI
ARG TGS_TELEMETRY_KEY_FILE=
# install node and npm
# replace shell with bash so we can source files
RUN curl --silent -o- https://raw.githubusercontent.com/creationix/nvm/v0.39.1/install.sh | sh
ENV NODE_VERSION 20.5.1
ENV NODE_VERSION=20.5.1
ENV NVM_DIR /root/.nvm
ENV NODE_PATH $NVM_DIR/v$NODE_VERSION/lib/node_modules
ENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH
ENV NVM_DIR=/root/.nvm
ENV NODE_PATH=$NVM_DIR/v$NODE_VERSION/lib/node_modules
ENV PATH=$NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH
RUN . $NVM_DIR/nvm.sh \
&& nvm install $NODE_VERSION \
@@ -50,8 +47,7 @@ RUN dotnet publish -c Release -o /app \
WORKDIR /repo/src/Tgstation.Server.Host
RUN export TGS_TELEMETRY_KEY_FILE="../../${TGS_TELEMETRY_KEY_FILE}" \
&& dotnet publish -c Release -o /app/lib/Default \
RUN dotnet publish -c Release -o /app/lib/Default \
&& cd ../.. \
&& build/RemoveUnsupportedRuntimes.sh /app/lib/Default \
&& mv /app/lib/Default/appsettings* /app
@@ -70,9 +66,9 @@ RUN dpkg --add-architecture i386 \
EXPOSE 5000
ENV General__ValidInstancePaths__0 /tgs_instances
ENV FileLogging__Directory /tgs_logs
ENV Internal__UsingDocker true
ENV General__ValidInstancePaths__0=/tgs_instances
ENV FileLogging__Directory=/tgs_logs
ENV Internal__UsingDocker=true
WORKDIR /app
+1 -1
View File
@@ -1,6 +1,6 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<!-- Usage: Primary JSON library -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" />
</ItemGroup>
</Project>
+1 -1
View File
@@ -25,7 +25,7 @@
<ItemGroup>
<!-- Usage: Sourcelink -->
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
+1 -1
View File
@@ -17,7 +17,7 @@
<ItemGroup>
<!-- Usage: Linting -->
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PackageReference Include="StyleCop.Analyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
+6 -6
View File
@@ -3,24 +3,24 @@
<ItemGroup>
<!-- Usage: Code coverage collection -->
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PackageReference Include="coverlet.collector">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- Usage: Logging specific for GitHub actions -->
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1">
<PackageReference Include="GitHubActionsTestLogger">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- Usage: Hard to say what exactly this is for, but not including it removes the test icon and breaks vstest.console.exe for some reason -->
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" Condition="'$(TgsTestNoSdk)' != 'true'" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Condition="'$(TgsTestNoSdk)' != 'true'" />
<!-- Usage: Dependency mocking for tests -->
<!-- Pinned: Be VERY careful about updating https://github.com/moq/moq/issues/1372 -->
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Moq" />
<!-- Usage: MSTest execution -->
<PackageReference Include="MSTest.TestAdapter" Version="3.9.3" />
<PackageReference Include="MSTest.TestAdapter" />
<!-- Usage: MSTest asserts etc... -->
<PackageReference Include="MSTest.TestFramework" Version="3.9.3" />
<PackageReference Include="MSTest.TestFramework" />
</ItemGroup>
</Project>
+9 -9
View File
@@ -3,22 +3,22 @@
<!-- Integration tests will ensure they match across the board -->
<Import Project="WebpanelVersion.props" />
<PropertyGroup>
<TgsCoreVersion>6.18.2</TgsCoreVersion>
<TgsConfigVersion>5.8.0</TgsConfigVersion>
<TgsRestVersion>10.13.0</TgsRestVersion>
<TgsGraphQLVersion>0.6.0</TgsGraphQLVersion>
<TgsCoreVersion>6.19.0</TgsCoreVersion>
<TgsConfigVersion>5.9.0</TgsConfigVersion>
<TgsRestVersion>10.14.0</TgsRestVersion>
<TgsGraphQLVersion>0.7.0</TgsGraphQLVersion>
<TgsCommonLibraryVersion>7.0.0</TgsCommonLibraryVersion>
<TgsApiLibraryVersion>18.0.0</TgsApiLibraryVersion>
<TgsClientVersion>21.0.0</TgsClientVersion>
<TgsDmapiVersion>7.3.3</TgsDmapiVersion>
<TgsInteropVersion>5.10.1</TgsInteropVersion>
<TgsApiLibraryVersion>18.2.0</TgsApiLibraryVersion>
<TgsClientVersion>21.2.0</TgsClientVersion>
<TgsDmapiVersion>7.4.0</TgsDmapiVersion>
<TgsInteropVersion>5.11.0</TgsInteropVersion>
<TgsHostWatchdogVersion>1.6.0</TgsHostWatchdogVersion>
<TgsSwarmProtocolVersion>9.0.0</TgsSwarmProtocolVersion>
<TgsContainerScriptVersion>1.2.1</TgsContainerScriptVersion>
<TgsNugetNetFramework>netstandard2.0</TgsNugetNetFramework>
<TgsNetMajorVersion>8</TgsNetMajorVersion>
<!-- Update this frequently with dotnet runtime patches. MAJOR MUST MATCH ABOVE! -->
<TgsDotnetRedistUrl>https://download.visualstudio.microsoft.com/download/pr/751d3fcd-72db-4da2-b8d0-709c19442225/33cc492bde704bfd6d70a2b9109005a0/dotnet-hosting-8.0.6-win.exe</TgsDotnetRedistUrl>
<TgsDotnetRedistUrl>https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/8.0.19/dotnet-hosting-8.0.19-win.exe</TgsDotnetRedistUrl>
<TgsMariaDBRedistVersion>11.4.2</TgsMariaDBRedistVersion>
<!-- Only have this uncommented if the mariadb servers are shitting the bed, update if the version updates -->
<!--<TgsMariaDBFallbackRedist>https://mirror.its.dal.ca/mariadb//mariadb-10.11.8/winx64-packages/mariadb-10.11.8-winx64.msi</TgsMariaDBFallbackRedist>-->
+2 -2
View File
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<RuleSet Name="myrules" Description="My rule set" ToolsVersion="17.0">
<Rules AnalyzerId="AsyncUsageAnalyzers" RuleNamespace="AsyncUsageAnalyzers">
<Rule Id="UseConfigureAwait" Action="Warning" />
</Rules>
<Rules AnalyzerId="Microsoft.Analyzers.ManagedCodeAnalysis" RuleNamespace="Microsoft.Rules.Managed">
<Rule Id="CA1000" Action="Warning" />
<Rule Id="CA1000" Action="None" />
<Rule Id="CA1001" Action="Warning" />
<Rule Id="CA1003" Action="Warning" />
<Rule Id="CA1004" Action="Warning" />
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
cd ../../../src/Tgstation.Server.Host.Console
dotnet restore --packages out
nuget-to-json out > ../../build/package/nix/deps.json
rm -rf out
+227
View File
@@ -0,0 +1,227 @@
[
{
"pname": "Microsoft.Build.Tasks.Git",
"version": "8.0.0",
"hash": "sha256-vX6/kPij8vNAu8f7rrvHHhPrNph20IcufmrBgZNxpQA="
},
{
"pname": "Microsoft.Extensions.Configuration",
"version": "9.0.8",
"hash": "sha256-GnD1Ar/yZfCZQw2k/2jKteLG1lF/Dk7S3tgMvn+SFqc="
},
{
"pname": "Microsoft.Extensions.Configuration.Abstractions",
"version": "9.0.8",
"hash": "sha256-hes+QZM3DQ1R/8CDOdWObk6s1oGhzFqka8Qc7Baf9PY="
},
{
"pname": "Microsoft.Extensions.Configuration.Binder",
"version": "9.0.8",
"hash": "sha256-N8WMvnbCKsUtpK08B1CYi5LOuq6Sbv3Nois4CKjDQJ8="
},
{
"pname": "Microsoft.Extensions.Configuration.CommandLine",
"version": "9.0.8",
"hash": "sha256-U8YasTaEsniloNaSPvlcXmGPfgTzjP8RvCfnLVx1onE="
},
{
"pname": "Microsoft.Extensions.Configuration.EnvironmentVariables",
"version": "9.0.8",
"hash": "sha256-g3FgfS11nS02dwDnpjpiD+r7nyUZ2eifGl8r2rfwanY="
},
{
"pname": "Microsoft.Extensions.Configuration.FileExtensions",
"version": "9.0.8",
"hash": "sha256-W7PnvqPcdJnJIPaEh1qRDh/WCVSz/KQy+GAMhMNhKE4="
},
{
"pname": "Microsoft.Extensions.Configuration.Json",
"version": "9.0.8",
"hash": "sha256-/QFT/SksJcsZ2Cjw0WkJzLnp+mT2m+38avEOgttrAaM="
},
{
"pname": "Microsoft.Extensions.Configuration.UserSecrets",
"version": "9.0.8",
"hash": "sha256-QcSfPQku3Hh5UIvuR3r3JYFDo2DFIZOx/i0/JmtOPWE="
},
{
"pname": "Microsoft.Extensions.DependencyInjection",
"version": "9.0.8",
"hash": "sha256-fJOwbtlmP6mXGYqHRCqtb7e08h5mFza6Wmd1NbNq3ug="
},
{
"pname": "Microsoft.Extensions.DependencyInjection.Abstractions",
"version": "9.0.8",
"hash": "sha256-uFBeyx8WTgDX2z8paf6ZAQ45WexaWG8uzO5x+qGrPRU="
},
{
"pname": "Microsoft.Extensions.Diagnostics",
"version": "9.0.8",
"hash": "sha256-jV71HdeEU/T60f5qr2ND5GY6/Qk4iPiMUbnjEq8S6Qo="
},
{
"pname": "Microsoft.Extensions.Diagnostics.Abstractions",
"version": "9.0.8",
"hash": "sha256-nsgRtkUUC5q+Wc76lu3xxRgOT0dw+EXGa4pFCeI0iEo="
},
{
"pname": "Microsoft.Extensions.FileProviders.Abstractions",
"version": "9.0.8",
"hash": "sha256-9X3roHvoAFzlTwVSlkbksB9EosKjVHeXuR5Jm682Wvk="
},
{
"pname": "Microsoft.Extensions.FileProviders.Physical",
"version": "9.0.8",
"hash": "sha256-lVnOgpxjO5VaCgviGeQ0R8kAIiDN1nKqpbj8CrCDpic="
},
{
"pname": "Microsoft.Extensions.FileSystemGlobbing",
"version": "9.0.8",
"hash": "sha256-1dmTABLD1Zo2vdZFsASTx8T8MRI8emN//KuNP3OiWKw="
},
{
"pname": "Microsoft.Extensions.Hosting",
"version": "9.0.8",
"hash": "sha256-0tnVesvcSrqvLarEVEf0kqJXdveLQCY7rCLis8b206Q="
},
{
"pname": "Microsoft.Extensions.Hosting.Abstractions",
"version": "9.0.8",
"hash": "sha256-N1XwGfMh2a9grBfObp9md7YPSm7rlNZO5NG8OmHn8W4="
},
{
"pname": "Microsoft.Extensions.Hosting.Systemd",
"version": "9.0.8",
"hash": "sha256-yFVkcy8UK36wAvVkrjg77y/8xFo0rvQNH0OrKApVNWE="
},
{
"pname": "Microsoft.Extensions.Logging",
"version": "9.0.8",
"hash": "sha256-SEVCMpVwjcQtTSs4lirb89A36MxLQwwqdDFWbr1VvP8="
},
{
"pname": "Microsoft.Extensions.Logging.Abstractions",
"version": "9.0.8",
"hash": "sha256-vaUApbwsqKt7+AItgusbCKKdTyOg/5KCdSZjDZarw20="
},
{
"pname": "Microsoft.Extensions.Logging.Configuration",
"version": "9.0.8",
"hash": "sha256-/6sIAQlXXDdWnERGXN9XqqOFf1Q71uFSMiThwdIPzEY="
},
{
"pname": "Microsoft.Extensions.Logging.Console",
"version": "9.0.8",
"hash": "sha256-8cz7l8ubkRIBd6gwDJcpJd66MYNU0LsvDH9+PU+mTJo="
},
{
"pname": "Microsoft.Extensions.Logging.Debug",
"version": "9.0.8",
"hash": "sha256-IPMzdLmY/l0IP25DTQ13qdA/wwX0V3wq+sTHp8bX0UM="
},
{
"pname": "Microsoft.Extensions.Logging.EventLog",
"version": "9.0.8",
"hash": "sha256-W/yr3lXCwbtY5DTq50q5vM4Jeibj8j//xZF2qa6lgcc="
},
{
"pname": "Microsoft.Extensions.Logging.EventSource",
"version": "9.0.8",
"hash": "sha256-AFjHG4fiHgbJbgcr6u0/M83vf0XtdoeA5ZGSrIV31Co="
},
{
"pname": "Microsoft.Extensions.Options",
"version": "9.0.8",
"hash": "sha256-AbwIL8sSZ/qDBKbvabHp1tbExBFr73fYjuXJiV6On1U="
},
{
"pname": "Microsoft.Extensions.Options.ConfigurationExtensions",
"version": "9.0.8",
"hash": "sha256-MwTPdC6kJ6Ff/Tw7BHa9JzRwBeCVdRS1gMz17agSjaY="
},
{
"pname": "Microsoft.Extensions.Primitives",
"version": "9.0.8",
"hash": "sha256-K3T8krgXZmvQg87AQQrn9kiH2sDyKzRUMDyuB/ItmPc="
},
{
"pname": "Microsoft.NETCore.Platforms",
"version": "1.1.0",
"hash": "sha256-FeM40ktcObQJk4nMYShB61H/E8B7tIKfl9ObJ0IOcCM="
},
{
"pname": "Microsoft.NETFramework.ReferenceAssemblies",
"version": "1.0.3",
"hash": "sha256-FBoJP5DHZF0QHM0xLm9yd4HJZVQOuSpSKA+VQRpphEE="
},
{
"pname": "Microsoft.NETFramework.ReferenceAssemblies.net20",
"version": "1.0.3",
"hash": "sha256-f3SZf+wzjd5w9ajJ/Qmqb+IShnMeexM8wTPWROTjxeg="
},
{
"pname": "Microsoft.SourceLink.Common",
"version": "8.0.0",
"hash": "sha256-AfUqleVEqWuHE7z2hNiwOLnquBJ3tuYtbkdGMppHOXc="
},
{
"pname": "Microsoft.SourceLink.GitHub",
"version": "8.0.0",
"hash": "sha256-hNTkpKdCLY5kIuOmznD1mY+pRdJ0PKu2HypyXog9vb0="
},
{
"pname": "Mono.Posix.NETStandard",
"version": "1.0.0",
"hash": "sha256-/F61k7MY/fu2FcfW7CkyjuUroKwlYAXPQFVeDs1QknY="
},
{
"pname": "NETStandard.Library",
"version": "2.0.3",
"hash": "sha256-Prh2RPebz/s8AzHb2sPHg3Jl8s31inv9k+Qxd293ybo="
},
{
"pname": "StyleCop.Analyzers",
"version": "1.2.0-beta.556",
"hash": "sha256-97YYQcr5vZxTvi36608eUkA1wb6xllZQ7UcXbjrYIfU="
},
{
"pname": "StyleCop.Analyzers.Unstable",
"version": "1.2.0.556",
"hash": "sha256-aVop7a9r+X2RsZETgngBm3qQPEIiPBWgHzicGSTEymc="
},
{
"pname": "System.Diagnostics.DiagnosticSource",
"version": "9.0.8",
"hash": "sha256-bVGcjEcZUVeY2jOvZeFnG+ym8ebUKOftx+gErNXeiK4="
},
{
"pname": "System.Diagnostics.EventLog",
"version": "9.0.8",
"hash": "sha256-u10dcgug0Pwp83YNagVto8Pu3ieuByflYLNwAdX9Fm0="
},
{
"pname": "System.IO.Pipelines",
"version": "9.0.8",
"hash": "sha256-dO84rUcpDgj6k8rTZq3Kmy/y+6c/cP1Fa1dsCV9JIlA="
},
{
"pname": "System.Runtime.CompilerServices.Unsafe",
"version": "6.1.2",
"hash": "sha256-X2p/U680Zfkr622oc+vg5JYgbDEzE7mLre5DVaayWTc="
},
{
"pname": "System.Text.Encodings.Web",
"version": "9.0.8",
"hash": "sha256-2Fy5/VAqBHuwcxHMVevyzQDRIRlyTZlT+5pwr6OtuuU="
},
{
"pname": "System.Text.Json",
"version": "9.0.8",
"hash": "sha256-CEoLOj0KeuctK2jXd6yZ+/5yx4apsEh7+xsJH95h/1c="
},
{
"pname": "System.Threading.Tasks.Extensions",
"version": "4.6.3",
"hash": "sha256-GrySx1F6Ah6tfnnQt/PHC+dbzg+sfP47OOFX0yJF/xo="
}
]
+15 -36
View File
@@ -27,44 +27,26 @@ let
installPhase = ''
mkdir -p $out
xmlstarlet sel -N X="http://schemas.microsoft.com/developer/msbuild/2003" --template --value-of /X:Project/X:PropertyGroup/X:TgsCoreVersion ./Version.props > $out/tgs_version.txt
xmlstarlet sel -N X="http://schemas.microsoft.com/developer/msbuild/2003" --template --value-of /X:Project/X:PropertyGroup/X:TgsHostWatchdogVersion ./Version.props > $out/watchdog_version.txt
'';
};
version = (builtins.readFile "${versionParse}/tgs_version.txt");
fixedOutput = stdenv.mkDerivation {
pname = "tgstation-server-release-server-console-zip";
inherit version;
tgstation-server-host-console = pkgs.buildDotnetModule {
pname = "Tgstation.Server.Host.Console";
version = (builtins.readFile "${versionParse}/watchdog_version.txt"); # Be careful, this influences the assembly version
meta = with pkgs.lib; {
description = "Host watchdog binaries for tgstation-server";
homepage = "https://github.com/tgstation/tgstation-server";
changelog = "https://github.com/tgstation/tgstation-server/releases/tag/tgstation-server-v${version}";
license = licenses.agpl3Plus;
platforms = platforms.x86_64;
};
src = ./../../..;
nativeBuildInputs = with pkgs; [
curl
cacert
versionParse
];
projectFile = "src/Tgstation.Server.Host.Console/Tgstation.Server.Host.Console.csproj";
nugetDeps = ./deps.json; # see "Generating and updating NuGet dependencies" section for details
src = ./.;
TGS_NIX_BUILD = "yes";
buildPhase = ''
curl -L https://github.com/tgstation/tgstation-server/releases/download/tgstation-server-v${version}/ServerConsole.zip -o ServerConsole.zip
'';
executables = [];
installPhase = ''
mkdir -p $out
mv ServerConsole.zip $out/ServerConsole.zip
'';
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash = (builtins.readFile ./ServerConsole.sha256);
dotnet-sdk = pkgs.dotnetCorePackages.sdk_8_0;
dotnet-runtime = pkgs.dotnetCorePackages.runtime_8_0;
};
rpath = lib.makeLibraryPath [ pkgs.stdenv_32bit.cc.cc.lib ];
in
stdenv.mkDerivation {
pname = "tgstation-server";
@@ -79,18 +61,17 @@ stdenv.mkDerivation {
};
buildInputs = with pkgs; [
dotnetCorePackages.sdk_8_0
pkgs.dotnetCorePackages.sdk_8_0
gdb
systemd
zlib
gcc_multi
glibc
bash
tgstation-server-host-console
];
nativeBuildInputs = with pkgs; [
makeWrapper
unzip
fixedOutput
versionParse
];
@@ -98,13 +79,11 @@ stdenv.mkDerivation {
installPhase = ''
mkdir -p $out/bin
unzip "${fixedOutput}/ServerConsole.zip" -d $out/bin
rm -rf $out/bin/lib
makeWrapper ${pkgs.dotnetCorePackages.sdk_8_0}/bin/dotnet $out/bin/tgstation-server --suffix PATH : ${
lib.makeBinPath (
with pkgs;
[
dotnetCorePackages.sdk_8_0
pkgs.dotnetCorePackages.sdk_8_0
gdb
bash
]
@@ -117,6 +96,6 @@ stdenv.mkDerivation {
zlib
]
)
} --add-flags "$out/bin/Tgstation.Server.Host.Console.dll --bootstrap"
} --add-flags "${tgstation-server-host-console}/lib/Tgstation.Server.Host.Console/Tgstation.Server.Host.Console.dll --bootstrap"
'';
}
+1 -1
View File
@@ -130,7 +130,7 @@ in
environment.etc = {
"tgstation-server.d/appsettings.yml" = {
text = (builtins.readFile "${package}/bin/appsettings.yml");
text = (builtins.readFile ./../../../src/Tgstation.Server.Host/appsettings.yml);
group = cfg.groupname;
mode = "0644";
};
@@ -24,8 +24,8 @@
<Content Include="Theme.xml" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="WixToolset.Bal.wixext" Version="5.0.2" />
<PackageReference Include="WixToolset.Netfx.wixext" Version="5.0.2" />
<PackageReference Include="WixToolset.Bal.wixext" />
<PackageReference Include="WixToolset.Netfx.wixext" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Tgstation.Server.Host.Service.Wix\Tgstation.Server.Host.Service.Wix.wixproj" />
@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="WixToolset.Dtf.CustomAction" Version="5.0.2" />
<PackageReference Include="WixToolset.Dtf.CustomAction" />
</ItemGroup>
<ItemGroup>
@@ -20,8 +20,8 @@
</HarvestDirectory>
</ItemGroup>
<ItemGroup>
<PackageReference Include="WixToolset.Heat" Version="5.0.2" />
<PackageReference Include="WixToolset.Util.wixext" Version="5.0.2" />
<PackageReference Include="WixToolset.Heat" />
<PackageReference Include="WixToolset.Util.wixext" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Tgstation.Server.Host.Service.Wix.Extensions\Tgstation.Server.Host.Service.Wix.Extensions.csproj" />
+1 -1
View File
@@ -1,7 +1,7 @@
{
"sdk": {
"version": "8.0.0",
"rollForward": "latestMajor",
"rollForward": "latestMinor",
"allowPrerelease": false
}
}
+8 -2
View File
@@ -1,7 +1,7 @@
// tgstation-server DMAPI
// The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in IETF RFC 2119.
#define TGS_DMAPI_VERSION "7.3.3"
#define TGS_DMAPI_VERSION "7.4.0"
// All functions and datums outside this document are subject to change with any version and should not be relied on.
@@ -584,11 +584,17 @@
/world/proc/TgsTriggerEvent(event_name, list/parameters, wait_for_completion = FALSE)
CAN_BE_REDEFINED(TRUE)
return
/// Trigger a TGS deployment for the current instance. The current state of the repository will not be changed.
/world/proc/TgsTriggerDeployment()
CAN_BE_REDEFINED(TRUE)
return
/*
The MIT License
Copyright (c) 2017-2024 Jordan Brown
Copyright (c) 2017-2026 Jordan Brown
Permission is hereby granted, free of charge,
to any person obtaining a copy of this software and
+3 -3
View File
@@ -5,9 +5,9 @@ This folder should be placed on its own inside a codebase that wishes to use the
- [includes.dm](./includes.dm) is the file that should be included by DM code, it handles including the rest.
- The [core](./core) folder includes all code not directly part of any API version.
- The other versioned folders contain code for the different DMAPI versions.
- [v3210](./v3210) contains the final TGS3 API.
- [v4](./v4) is the legacy DMAPI 4 (Used in TGS 4.0.X versions).
- [v5](./v5) is the current DMAPI version used by TGS >=4.1.
- [v3210](./v3210) contains the final TGS3 API.
- [v4](./v4) is the legacy DMAPI 4 (Used in TGS 4.0.X versions).
- [v5](./v5) is the current DMAPI version used by TGS >=4.1.
- [LICENSE](./LICENSE) is the MIT license for the DMAPI.
APIs communicate with TGS in two ways. All versions implement TGS -> DM communication using /world/Topic. DM -> TGS communication, called the bridge method, is different for each version.
+1 -1
View File
@@ -2,7 +2,7 @@
This folder contains all DMAPI code not directly involved in an API.
- [_definitions.dm](./definitions.dm) contains defines needed across DMAPI internals.
- [\_definitions.dm](./definitions.dm) contains defines needed across DMAPI internals.
- [byond_world_export.dm](./byond_world_export.dm) contains the default `/datum/tgs_http_handler` implementation which uses `world.Export()`.
- [core.dm](./core.dm) contains the implementations of the `/world/proc/TgsXXX()` procs. Many map directly to the `/datum/tgs_api` functions. It also contains the /datum selection and setup code.
- [datum.dm](./datum.dm) contains the `/datum/tgs_api` declarations that all APIs must implement.
+5
View File
@@ -177,3 +177,8 @@
parameters = list()
return api.TriggerEvent(event_name, parameters, wait_for_completion)
/world/TgsTriggerDeployment()
var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs)
if(api)
return api.TriggerDeployment()
+3
View File
@@ -72,3 +72,6 @@ TGS_PROTECT_DATUM(/datum/tgs_api)
/datum/tgs_api/proc/TriggerEvent(event_name, list/parameters, wait_for_completion)
return FALSE
/datum/tgs_api/proc/TriggerDeployment()
return TGS_UNIMPLEMENTED
+2 -2
View File
@@ -2,8 +2,8 @@
This DMAPI implements bridge requests using HTTP GET requests to TGS. It has no security restrictions.
- [__interop_version.dm](./__interop_version.dm) contains the version of the API used between the DMAPI and TGS.
- [_defines.dm](./_defines.dm) contains constant definitions.
- [\_\_interop_version.dm](./__interop_version.dm) contains the version of the API used between the DMAPI and TGS.
- [\_defines.dm](./_defines.dm) contains constant definitions.
- [api.dm](./api.dm) contains the bulk of the API code.
- [bridge.dm](./bridge.dm) contains functions related to making bridge requests.
- [chunking.dm](./chunking.dm) contains common function for splitting large raw data sets into chunks BYOND can natively process.
+1 -1
View File
@@ -1 +1 @@
"5.10.1"
"5.11.0"
+1
View File
@@ -15,6 +15,7 @@
#define DMAPI5_BRIDGE_COMMAND_CHAT_SEND 5
#define DMAPI5_BRIDGE_COMMAND_CHUNK 6
#define DMAPI5_BRIDGE_COMMAND_EVENT 7
#define DMAPI5_BRIDGE_COMMAND_DEPLOY 8
#define DMAPI5_PARAMETER_ACCESS_IDENTIFIER "accessIdentifier"
#define DMAPI5_PARAMETER_CUSTOM_COMMANDS "customCommands"
+13
View File
@@ -294,6 +294,19 @@
return TRUE
/datum/tgs_api/v5/TriggerDeployment(event_name, list/parameters, wait_for_completion)
RequireInitialBridgeResponse()
WaitForReattach(TRUE)
if(interop_version.minor < 11)
TGS_WARNING_LOG("Interop version too low for triggering deployments!")
return FALSE
var response = Bridge(DMAPI5_BRIDGE_COMMAND_DEPLOY)
if(!response)
return FALSE
return TRUE
/datum/tgs_api/v5/proc/DecodeChannels(chat_update_json)
TGS_DEBUG_LOG("DecodeChannels()")
var/list/chat_channels_json = chat_update_json[DMAPI5_CHAT_UPDATE_CHANNELS]
+1
View File
@@ -15,6 +15,7 @@
#undef DMAPI5_BRIDGE_COMMAND_CHAT_SEND
#undef DMAPI5_BRIDGE_COMMAND_CHUNK
#undef DMAPI5_BRIDGE_COMMAND_EVENT
#undef DMAPI5_BRIDGE_COMMAND_DEPLOY
#undef DMAPI5_PARAMETER_ACCESS_IDENTIFIER
#undef DMAPI5_PARAMETER_CUSTOM_COMMANDS
+1 -1
View File
@@ -493,7 +493,7 @@ namespace Tgstation.Server.Api.Models
/// <summary>
/// Attempted to restart a stopped watchdog.
/// </summary>
[Description("Cannot restart the watchdog as it is not running!")]
[Description("Cannot perform watchdog operation as it is not running!")]
WatchdogNotRunning,
/// <summary>
@@ -14,6 +14,13 @@ namespace Tgstation.Server.Api.Models.Internal
[ResponseOptions]
public long? SessionId { get; set; }
/// <summary>
/// A incrementing ID for representing current iteration of servers world (i.e. after calling /world/proc/Reboot). Only unique within the current <see cref="SessionId"/>. Only tracked in game sessions with the DMAPI enabled.
/// </summary>
/// <example>1</example>
[ResponseOptions]
public long? WorldIteration { get; set; }
/// <summary>
/// When the current server execution was started.
/// </summary>
@@ -33,6 +33,12 @@ namespace Tgstation.Server.Api.Rights
/// <returns>The <see cref="Enum"/> <see cref="Type"/> of the given <paramref name="rightsType"/>.</returns>
public static Type RightToType(RightsType rightsType) => TypeMap[rightsType];
/// <summary>
/// Iterate the <see cref="Type"/> of each right.
/// </summary>
/// <returns>An <see cref="IEnumerable{T}"/> of each <see cref="Type"/> of right.</returns>
public static IEnumerable<Type> AllRightTypes() => TypeMap.Values;
/// <summary>
/// Map a given <typeparamref name="TRight"/> to its respective <see cref="RightsType"/>.
/// </summary>
@@ -26,11 +26,11 @@
<ItemGroup>
<!-- Usage: HTTP constants reference -->
<PackageReference Include="Microsoft.AspNetCore.Http.Extensions" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Extensions" />
<!-- Usage: Decoding the 'nbf' property of JWTs -->
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.13.0" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" />
<!-- Usage: Data model annotating -->
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageReference Include="System.ComponentModel.Annotations" />
</ItemGroup>
<ItemGroup>
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"strawberryshake.tools": {
"version": "13.9.12",
"version": "15.1.9",
"commands": [
"dotnet-graphql"
]
@@ -9,7 +9,7 @@ mutation CreateSystemUserWithPermissionSet($systemIdentifier: String!) {
message
}
}
user {
updatedUser {
id
}
}
@@ -7,7 +7,7 @@ mutation CreateUserFromOAuthConnection($name: String!, $oAuthConnections: [OAuth
message
}
}
user {
updatedUser {
id
}
}
@@ -1,5 +1,24 @@
mutation CreateUserGroupWithInstanceListPerm($name: String!) {
createUserGroup(input: { name: $name, permissionSet: { instanceManagerRights: { canList: true } } }) {
createUserGroup(input: {
name: $name
permissionSet: {
instanceManagerRights: {
canList: true
canCreate: false
canDelete: false
canGrantPermissions: false
canRead: false
canRelocate: false
canRename: false
canSetAutoUpdate: false
canSetChatBotLimit: false
canSetConfiguration: false
canSetOnline: false
canSetAutoStart: false
canSetAutoStop: false
}
}
}) {
userGroup {
id
name
@@ -7,7 +7,7 @@ mutation CreateUserWithPassword($name: String!, $password: String!) {
message
}
}
user {
updatedUser {
id
}
}
@@ -1,10 +1,12 @@
mutation CreateUserWithPasswordSelectOAuthConnections($name: String!, $password: String!) {
createUserByPasswordAndPermissionSet(input: { name: $name, password: $password }) {
user {
updatedUser {
id
oAuthConnections {
externalUserId
provider
user {
oAuthConnections {
externalUserId
provider
}
}
}
errors {
@@ -25,6 +25,8 @@ mutation SetFullPermsOnUserGroup($id: ID!) {
canRead: true
canList: true
canRelocate: true
canSetAutoStart: true
canSetAutoStop: true
}
}
}
@@ -7,35 +7,37 @@ mutation SetUserGroup($id: ID!, $newGroupId: ID!) {
message
}
}
user {
ownedPermissionSet {
instanceManagerRights {
canCreate
canDelete
canGrantPermissions
canList
canRead
canRelocate
canRename
canSetAutoUpdate
canSetChatBotLimit
canSetConfiguration
canSetOnline
updatedUser {
user {
ownedPermissionSet {
instanceManagerRights {
canCreate
canDelete
canGrantPermissions
canList
canRead
canRelocate
canRename
canSetAutoUpdate
canSetChatBotLimit
canSetConfiguration
canSetOnline
}
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
canUploadVersion
canWriteUsers
}
}
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
canUploadVersion
canWriteUsers
group {
id
}
}
group {
id
}
}
}
}
@@ -2,17 +2,19 @@ mutation SetUserOAuthConnections($id: ID!, $newOAuthConnections: [OAuthConnectio
updateUser(
input: { id: $id, newOAuthConnections: $newOAuthConnections }
) {
user {
canonicalName
createdAt
enabled
id
name
systemIdentifier
oAuthConnections {
externalUserId
provider
}
updatedUser {
user {
canonicalName
createdAt
enabled
id
name
systemIdentifier
oAuthConnections {
externalUserId
provider
}
}
}
errors {
... on ErrorMessageError {
@@ -7,60 +7,62 @@ mutation SetUserPermissionSet($id: ID!, $permissionSet: PermissionSetInput!) {
message
}
}
user {
effectivePermissionSet {
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
canUploadVersion
canWriteUsers
updatedUser {
user {
effectivePermissionSet {
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
canUploadVersion
canWriteUsers
}
instanceManagerRights {
canCreate
canDelete
canGrantPermissions
canList
canRead
canRelocate
canRename
canSetAutoUpdate
canSetChatBotLimit
canSetConfiguration
canSetOnline
}
}
ownedPermissionSet {
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
canUploadVersion
canWriteUsers
}
instanceManagerRights {
canCreate
canDelete
canGrantPermissions
canList
canRead
canRelocate
canRename
canSetAutoUpdate
canSetChatBotLimit
canSetConfiguration
canSetOnline
}
}
group {
id
}
}
instanceManagerRights {
canCreate
canDelete
canGrantPermissions
canList
canRead
canRelocate
canRename
canSetAutoUpdate
canSetChatBotLimit
canSetConfiguration
canSetOnline
}
}
ownedPermissionSet {
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
canUploadVersion
canWriteUsers
}
instanceManagerRights {
canCreate
canDelete
canGrantPermissions
canList
canRead
canRelocate
canRename
canSetAutoUpdate
canSetChatBotLimit
canSetConfiguration
canSetOnline
}
}
group {
id
}
}
}
}
@@ -1,10 +1,12 @@
mutation UpdateUserOAuthConnections($id: ID!, $newOAuthConnections: [OAuthConnectionInput!]) {
updateUser(input: { id: $id, newOAuthConnections: $newOAuthConnections }) {
user {
updatedUser {
id
oAuthConnections {
externalUserId
provider
user {
oAuthConnections {
externalUserId
provider
}
}
}
errors {
@@ -1,38 +1,36 @@
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
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
canUploadVersion
canWriteUsers
}
userGroups {
byId(id: $id) {
permissionSet {
instanceManagerRights {
canCreate
canDelete
canGrantPermissions
canList
canRead
canRelocate
canRename
canSetAutoUpdate
canSetChatBotLimit
canSetConfiguration
canSetOnline
}
queryableUsersByGroup(first: 1) {
totalCount
nodes {
id
}
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
canUploadVersion
canWriteUsers
}
}
queryableUsersByGroup(first: 1) {
totalCount
nodes {
id
}
}
}
@@ -1,12 +1,10 @@
query ListUserGroups {
swarm {
users {
groups {
queryableGroups {
totalCount
nodes {
id
}
userGroups {
queryableGroups {
totalCount
nodes {
id
}
}
}
@@ -9,28 +9,24 @@
<ItemGroup>
<!-- GraphQL connector and code generator -->
<PackageReference Include="StrawberryShake.Server" Version="15.1.8" />
<PackageReference Include="StrawberryShake.Server" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Tgstation.Server.Client\Tgstation.Server.Client.csproj" />
<ProjectReference Include="..\Tgstation.Server.Host\Tgstation.Server.Host.csproj" PrivateAssets="all" ReferenceOutputAssembly="false" />
</ItemGroup>
<!-- https://github.com/dotnet/msbuild/issues/2661#issuecomment-338808147 -->
<Target Name="WorkaroundSdk939" BeforeTargets="ImportGraphQLApiSchema">
<MSBuild Projects="..\Tgstation.Server.Host\Tgstation.Server.Host.csproj" />
</Target>
<Target Name="DeleteGeneratedFiles" BeforeTargets="ImportGraphQLApiSchema">
<RemoveDir Directories="$(IntermediateOutputPath)berry" />
</Target>
<!-- https://github.com/ChilliCream/graphql-platform/blob/c0c8df525ca0f47bf3b3b409a8b22cbe37f7a9c0/src/StrawberryShake/MetaPackages/Common/MSBuild/StrawberryShake.targets#L20 -->
<Target Name="ImportGraphQLApiSchema" BeforeTargets="_GraphQLCodeGenerationRoot" Inputs="../../artifacts/tgs-api.graphql" Outputs="schema.graphql">
<Target Name="ImportGraphQLApiSchema" BeforeTargets="_GraphQLCodeGenerationRoot" DependsOnTargets="ResolveProjectReferences" Inputs="../../artifacts/tgs-api.graphql" Outputs="schema.graphql">
<Copy SkipUnchangedFiles="true" SourceFiles="../../artifacts/tgs-api.graphql" DestinationFiles="schema.graphql" />
</Target>
<Target Name="FixWarningsInGeneratedSchema" AfterTargets="GenerateGraphQLCode">
<Target Name="FixWarningsInGeneratedSchema" AfterTargets="GenerateGraphQLCode" BeforeTargets="CoreCompile">
<PropertyGroup>
<InputFile>$(IntermediateOutputPath)berry/GraphQLClient.Client.cs</InputFile>
<OutputFile>$(IntermediateOutputPath)berry/GraphQLClient.Client.cs</OutputFile>
+3 -3
View File
@@ -69,9 +69,9 @@ namespace Tgstation.Server.Client
};
/// <summary>
/// The <see cref="IHttpClient"/> for the <see cref="ApiClient"/>.
/// The <see cref="HttpClient"/> for the <see cref="ApiClient"/>.
/// </summary>
readonly IHttpClient httpClient;
readonly HttpClient httpClient;
/// <summary>
/// The <see cref="IRequestLogger"/>s used by the <see cref="ApiClient"/>.
@@ -166,7 +166,7 @@ namespace Tgstation.Server.Client
/// <param name="tokenRefreshHeaders">The value of <see cref="tokenRefreshHeaders"/>.</param>
/// <param name="authless">The value of <see cref="authless"/>.</param>
public ApiClient(
IHttpClient httpClient,
HttpClient httpClient,
Uri url,
ApiHeaders apiHeaders,
ApiHeaders? tokenRefreshHeaders,
@@ -1,7 +1,7 @@
using System;
using System.Net.Http;
using Tgstation.Server.Api;
using Tgstation.Server.Common.Http;
namespace Tgstation.Server.Client
{
@@ -19,5 +19,19 @@ namespace Tgstation.Server.Client
apiHeaders,
tokenRefreshHeaders,
authless);
/// <inheritdoc />
public IApiClient CreateApiClient(
Uri url,
ApiHeaders apiHeaders,
ApiHeaders? tokenRefreshHeaders,
HttpMessageHandler handler,
bool disposeHandler,
bool authless) => new ApiClient(
new HttpClient(handler, disposeHandler),
url,
apiHeaders,
tokenRefreshHeaders,
authless);
}
}
@@ -1,4 +1,5 @@
using System;
using System.Net.Http;
using Tgstation.Server.Api;
@@ -22,5 +23,23 @@ namespace Tgstation.Server.Client
ApiHeaders apiHeaders,
ApiHeaders? tokenRefreshHeaders,
bool authless);
/// <summary>
/// Create an <see cref="IApiClient"/>.
/// </summary>
/// <param name="url">The base <see cref="Uri"/>.</param>
/// <param name="apiHeaders">The <see cref="ApiHeaders"/> for the <see cref="IApiClient"/>.</param>
/// <param name="tokenRefreshHeaders">The <see cref="ApiHeaders"/> to use to generate a new <see cref="Api.Models.Response.TokenResponse"/>.</param>
/// <param name="handler">The <see cref="HttpMessageHandler"/> to use with the internal <see cref="HttpClient"/>.</param>
/// <param name="disposeHandler">If <paramref name="handler"/> should be disposed with the created <see cref="IApiClient"/>.</param>
/// <param name="authless">If there should be no authentication performed.</param>
/// <returns>A new <see cref="IApiClient"/>.</returns>
public IApiClient CreateApiClient(
Uri url,
ApiHeaders apiHeaders,
ApiHeaders? tokenRefreshHeaders,
HttpMessageHandler handler,
bool disposeHandler,
bool authless);
}
}
@@ -11,9 +11,9 @@
<ItemGroup>
<!-- Usage: Connecting to SignalR hubs in API -->
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.7" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
<!-- Usage: Using target JSON serializer for API -->
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="9.0.7" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" />
</ItemGroup>
<ItemGroup>
@@ -36,7 +36,7 @@ namespace Tgstation.Server.Common.Http
response.Content = null;
try
{
// don't cry about the missing CancellationToken overload: https://github.com/dotnet/runtime/issues/916
// don't cry about the missing CancellationToken overload: https://github.com/dotnet/corefx/issues/32615#issuecomment-562083237
var responseStream = await content.ReadAsStreamAsync().ConfigureAwait(false);
return new CachedResponseStream(content, responseStream);
}
@@ -1,51 +0,0 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
namespace Tgstation.Server.Common.Http
{
/// <inheritdoc />
public sealed class HttpClient : IHttpClient
{
/// <inheritdoc />
public TimeSpan Timeout
{
get => httpClient.Timeout;
set => httpClient.Timeout = value;
}
/// <inheritdoc />
public HttpRequestHeaders DefaultRequestHeaders => httpClient.DefaultRequestHeaders;
/// <summary>
/// The real <see cref="System.Net.Http.HttpClient"/>.
/// </summary>
readonly System.Net.Http.HttpClient httpClient;
/// <summary>
/// Initializes a new instance of the <see cref="HttpClient"/> class.
/// </summary>
/// <param name="implementation">The <see cref="System.Net.Http.HttpClient"/> to wrap.</param>
public HttpClient(System.Net.Http.HttpClient implementation)
{
httpClient = implementation ?? throw new ArgumentNullException(nameof(implementation));
}
/// <summary>
/// Initializes a new instance of the <see cref="HttpClient"/> class.
/// </summary>
public HttpClient()
: this(new System.Net.Http.HttpClient())
{
}
/// <inheritdoc />
public void Dispose() => httpClient.Dispose();
/// <inheritdoc />
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
=> httpClient.SendAsync(request, completionOption, cancellationToken);
}
}
@@ -1,41 +0,0 @@
using System;
using System.Net.Http.Headers;
namespace Tgstation.Server.Common.Http
{
/// <summary>
/// <see cref="IAbstractHttpClientFactory"/> that creates <see cref="HttpClient"/>s.
/// </summary>
public sealed class HttpClientFactory : IAbstractHttpClientFactory
{
/// <inheritdoc />
public IHttpClient CreateClient()
{
var client = new HttpClient();
try
{
client.DefaultRequestHeaders.UserAgent.Add(userAgent);
return client;
}
catch
{
client.Dispose();
throw;
}
}
/// <summary>
/// The <see cref="ProductInfoHeaderValue"/> used as created client's User-Agent header on request.
/// </summary>
readonly ProductInfoHeaderValue userAgent;
/// <summary>
/// Initializes a new instance of the <see cref="HttpClientFactory"/> class.
/// </summary>
/// <param name="userAgent">The value of <see cref="userAgent"/>.</param>
public HttpClientFactory(ProductInfoHeaderValue userAgent)
{
this.userAgent = userAgent ?? throw new ArgumentNullException(nameof(userAgent));
}
}
}
@@ -1,14 +0,0 @@
namespace Tgstation.Server.Common.Http
{
/// <summary>
/// Creates <see cref="IHttpClient"/>s.
/// </summary>
public interface IAbstractHttpClientFactory
{
/// <summary>
/// Create a <see cref="IHttpClient"/>.
/// </summary>
/// <returns>A new <see cref="IHttpClient"/>.</returns>
IHttpClient CreateClient();
}
}
@@ -1,33 +0,0 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
namespace Tgstation.Server.Common.Http
{
/// <summary>
/// For sending HTTP requests.
/// </summary>
public interface IHttpClient : IDisposable
{
/// <summary>
/// The request timeout.
/// </summary>
TimeSpan Timeout { get; set; }
/// <summary>
/// The <see cref="HttpRequestHeaders"/> used on every request.
/// </summary>
HttpRequestHeaders DefaultRequestHeaders { get; }
/// <summary>
/// Send an HTTP request.
/// </summary>
/// <param name="request">The <see cref="HttpRequestMessage"/>.</param>
/// <param name="completionOption">The <see cref="HttpCompletionOption"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="HttpResponseMessage"/> of the request.</returns>
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken);
}
}
@@ -12,10 +12,10 @@
<ItemGroup>
<!-- Usage: ValueTask netstandard backport -->
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageReference Include="System.Threading.Tasks.Extensions" />
</ItemGroup>
<Target Name="IconGeneration" BeforeTargets="ResolveAssemblyReferences" Inputs="../../build/logo.svg" Outputs="../../artifacts/tgs.ico;../../artifacts/tgs.png;../../tools/Tgstation.Server.LogoGenerator/Program.cs">
<Target Name="IconGeneration" BeforeTargets="ResolveAssemblyReferences" Condition="'$(TGS_NIX_BUILD)' == ''" Inputs="../../build/logo.svg" Outputs="../../artifacts/tgs.ico;../../artifacts/tgs.png;../../tools/Tgstation.Server.LogoGenerator/Program.cs">
<Message Text="Generating icons from SVG..." Importance="high" />
<Exec Command="dotnet run --project tools/Tgstation.Server.LogoGenerator -c $(Configuration)" WorkingDirectory="../.." />
</Target>
@@ -13,9 +13,9 @@
<ItemGroup>
<!-- Usage: Identifying if we're running under SystemD -->
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.7" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" />
<!-- Usage: Console logging plugin -->
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
</ItemGroup>
<ItemGroup>
@@ -17,25 +17,25 @@
<ItemGroup>
<!-- Usage: Installing service programatically -->
<PackageReference Include="Core.System.ServiceProcess" Version="2.0.1" />
<PackageReference Include="Core.System.ServiceProcess" />
<!-- Usage: Command line argument support -->
<PackageReference Include="McMaster.Extensions.CommandLineUtils" Version="4.1.1" />
<PackageReference Include="McMaster.Extensions.CommandLineUtils" />
<!-- Usage: Identifies when we are running in the context of the Windows SCM -->
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.7" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
<!-- Usage: Windows event log logging plugin -->
<PackageReference Include="Microsoft.Extensions.Logging.EventLog" Version="9.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging.EventLog" />
<!-- Usage: Console logging plugin -->
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<!-- Usage: Updated transitive dependency of Core.System.ServiceProcess -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" />
<!-- Usage: Updated transitive dependency of Core.System.ServiceProcess -->
<PackageReference Include="System.Drawing.Common" Version="9.0.7" />
<PackageReference Include="System.Drawing.Common" />
<!-- Usage: Updated transitive dependency, unable to tell what of -->
<PackageReference Include="System.Private.Uri" Version="4.3.2" />
<PackageReference Include="System.Private.Uri" />
<!-- Usage: OS identification -->
<PackageReference Include="System.Runtime.InteropServices" Version="4.3.0" />
<PackageReference Include="System.Runtime.InteropServices" />
<!-- Usage: Windows Service Manager intergration -->
<PackageReference Include="System.ServiceProcess.ServiceController" Version="9.0.7" />
<PackageReference Include="System.ServiceProcess.ServiceController" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"strawberryshake.tools": {
"version": "15.1.9",
"commands": [
"dotnet-graphql"
],
"rollForward": false
}
}
}
@@ -4,12 +4,9 @@
"extensions": {
"strawberryShake": {
"name": "GraphQLClient",
"url": "../../artifacts/gitlab-api.graphql",
"url": "https://gitlab.com/api/graphql",
"namespace": "Tgstation.Server.Host.Utils.GitLab.GraphQL",
"records": {
"inputs": false,
"entities": false
},
"dependencyInjection": true,
"transportProfiles": [
{
"default": "Http",
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../../build/SrcCommon.props" />
<PropertyGroup>
@@ -7,28 +7,24 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<Target Name="InstallApollo" Inputs="package.json;yarn.lock" Outputs="node_modules/.bin/apollo">
<Message Text="Installing Apollo..." Importance="high" />
<Exec Command="yarn install --immutable" />
</Target>
<Target Name="CleanApollo" AfterTargets="Clean">
<RemoveDir Directories="node_modules" />
<RemoveDir Directories=".yarn" />
</Target>
<Target Name="GetApi" DependsOnTargets="InstallApollo" Inputs="node_modules/.bin/apollo" Outputs="../../artifacts/gitlab-api.graphql">
<Target Name="GetApi" Outputs="../../artifacts/gitlab-api.graphql" Condition="!Exists('../../artifacts/gitlab-api.graphql')">
<Message Text="Fetching GitLab GraphQL API schema..." Importance="high" />
<MakeDir Directories="../../artifacts" />
<Exec Command="node_modules/.bin/apollo client:download-schema --endpoint=https://gitlab.com/api/graphql ../../artifacts/gitlab-api.graphql" />
<Exec Command="dotnet tool restore" />
<Exec Command="dotnet graphql download -f ../../artifacts/gitlab-api.graphql https://gitlab.com/api/graphql" />
</Target>
<!-- https://github.com/ChilliCream/graphql-platform/blob/c0c8df525ca0f47bf3b3b409a8b22cbe37f7a9c0/src/StrawberryShake/MetaPackages/Common/MSBuild/StrawberryShake.targets#L20 -->
<Target Name="ImportGraphQLApiSchema" DependsOnTargets="GetApi" BeforeTargets="_GraphQLCodeGenerationRoot" Inputs="../../artifacts/gitlab-api.graphql" Outputs="schema.graphql">
<Copy SkipUnchangedFiles="true" SourceFiles="../../artifacts/gitlab-api.graphql" DestinationFiles="schema.graphql" />
<WriteLinesToFile File="schema.graphql" Lines="$([System.IO.File]::ReadAllText('schema.graphql').Replace('\', ''))" Overwrite="true" Encoding="UTF-8" />
</Target>
<!-- This should be able to be removed with ChilliCream v16+ -->
<Target Name="FixWarningsInGeneratedSchema" AfterTargets="GenerateGraphQLCode">
<PropertyGroup>
<InputFile>$(IntermediateOutputPath)berry/GraphQLClient.Client.cs</InputFile>
@@ -39,7 +35,7 @@
<ItemGroup>
<!-- GraphQL code generator -->
<PackageReference Include="StrawberryShake.Server" Version="15.1.8" />
<PackageReference Include="StrawberryShake.Server" />
</ItemGroup>
<ItemGroup>
@@ -1,7 +0,0 @@
{
"license": "AGPL-3.0-only",
"devDependencies": {
"apollo": "^2.34.0"
},
"packageManager": "yarn@4.7.0"
}
File diff suppressed because it is too large Load Diff
@@ -10,9 +10,9 @@
<ItemGroup>
<!-- Usage: Logging abstractions -->
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<!-- Usage: POSIX support for signals -->
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
<PackageReference Include="Mono.Posix.NETStandard" />
</ItemGroup>
<ItemGroup>
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "9.0.7",
"version": "9.0.8",
"commands": [
"dotnet-ef"
]
@@ -1,9 +1,12 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Octokit;
@@ -11,9 +14,11 @@ 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.Core;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.System;
using Tgstation.Server.Host.Transfer;
using Tgstation.Server.Host.Utils.GitHub;
@@ -57,10 +62,29 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
readonly IMemoryCache cacheService;
/// <summary>
/// The <see cref="IAssemblyInformationProvider"/> for the <see cref="AdministrationAuthority"/>.
/// </summary>
readonly IAssemblyInformationProvider assemblyInformationProvider;
/// <summary>
/// The <see cref="IPlatformIdentifier"/> for the <see cref="AdministrationAuthority"/>.
/// </summary>
readonly IPlatformIdentifier platformIdentifier;
/// <summary>
/// The <see cref="IIOManager"/> for the <see cref="AdministrationAuthority"/>.
/// </summary>
readonly IIOManager ioManager;
/// <summary>
/// The <see cref="FileLoggingConfiguration"/> for the <see cref="AdministrationAuthority"/>.
/// </summary>
readonly IOptionsSnapshot<FileLoggingConfiguration> fileLoggingConfigurationOptions;
/// <summary>
/// Initializes a new instance of the <see cref="AdministrationAuthority"/> class.
/// </summary>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to use.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="gitHubServiceFactory">The value of <see cref="gitHubServiceFactory"/>.</param>
@@ -68,17 +92,23 @@ namespace Tgstation.Server.Host.Authority
/// <param name="serverUpdateInitiator">The value of <see cref="serverUpdateInitiator"/>.</param>
/// <param name="fileTransferService">The value of <see cref="fileTransferService"/>.</param>
/// <param name="cacheService">The value of <see cref="cacheService"/>.</param>
/// <param name="assemblyInformationProvider">The value of <see cref="assemblyInformationProvider"/>.</param>
/// <param name="platformIdentifier">The value of <see cref="platformIdentifier"/>.</param>
/// <param name="ioManager">The value of <see cref="ioManager"/>.</param>
/// <param name="fileLoggingConfigurationOptions">The value of <see cref="fileLoggingConfigurationOptions"/>.</param>
public AdministrationAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<UserAuthority> logger,
IGitHubServiceFactory gitHubServiceFactory,
IServerControl serverControl,
IServerUpdateInitiator serverUpdateInitiator,
IFileTransferTicketProvider fileTransferService,
IMemoryCache cacheService)
IMemoryCache cacheService,
IAssemblyInformationProvider assemblyInformationProvider,
IPlatformIdentifier platformIdentifier,
IIOManager ioManager,
IOptionsSnapshot<FileLoggingConfiguration> fileLoggingConfigurationOptions)
: base(
authenticationContext,
databaseContext,
logger)
{
@@ -87,157 +117,212 @@ namespace Tgstation.Server.Host.Authority
this.serverUpdateInitiator = serverUpdateInitiator ?? throw new ArgumentNullException(nameof(serverUpdateInitiator));
this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService));
this.cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService));
this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier));
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
this.fileLoggingConfigurationOptions = fileLoggingConfigurationOptions ?? throw new ArgumentNullException(nameof(fileLoggingConfigurationOptions));
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<AdministrationResponse>> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken)
{
try
{
async Task<AdministrationResponse> CacheFactory()
public RequirementsGated<AuthorityResponse<AdministrationResponse>> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken)
=> new(
() => Flag(AdministrationRights.ChangeVersion),
async () =>
{
Version? greatestVersion = null;
Uri? repoUrl = null;
var scopeCancellationToken = CancellationToken.None; // DCT: None available
try
{
var gitHubService = await gitHubServiceFactory.CreateService(scopeCancellationToken);
var repositoryUrlTask = gitHubService.GetUpdatesRepositoryUrl(scopeCancellationToken);
var releases = await gitHubService.GetTgsReleases(scopeCancellationToken);
foreach (var kvp in releases)
async Task<AdministrationResponse> CacheFactory()
{
var version = kvp.Key;
var release = kvp.Value;
if (version.Major > 3 // Forward/backward compatible but not before TGS4
&& (greatestVersion == null || version > greatestVersion))
greatestVersion = version;
Version? greatestVersion = null;
Uri? repoUrl = null;
var scopeCancellationToken = CancellationToken.None; // DCT: None available
try
{
var gitHubService = await gitHubServiceFactory.CreateService(scopeCancellationToken);
var repositoryUrlTask = gitHubService.GetUpdatesRepositoryUrl(scopeCancellationToken);
var releases = await gitHubService.GetTgsReleases(scopeCancellationToken);
foreach (var kvp in releases)
{
var version = kvp.Key;
var release = kvp.Value;
if (version.Major > 3 // Forward/backward compatible but not before TGS4
&& (greatestVersion == null || version > greatestVersion))
greatestVersion = version;
}
repoUrl = await repositoryUrlTask;
}
catch (NotFoundException e)
{
Logger.LogWarning(e, "Not found exception while retrieving upstream repository info!");
}
return new AdministrationResponse
{
LatestVersion = greatestVersion,
TrackedRepositoryUrl = repoUrl,
GeneratedAt = DateTimeOffset.UtcNow,
};
}
repoUrl = await repositoryUrlTask;
var ttl = TimeSpan.FromMinutes(30);
Task<AdministrationResponse> task;
if (forceFresh || !cacheService.TryGetValue(ReadCacheKey, out var rawCacheObject))
{
using var entry = cacheService.CreateEntry(ReadCacheKey);
entry.AbsoluteExpirationRelativeToNow = ttl;
entry.Value = task = CacheFactory();
}
else
task = (Task<AdministrationResponse>)rawCacheObject!;
var result = await task.WaitAsync(cancellationToken);
return new AuthorityResponse<AdministrationResponse>(result);
}
catch (NotFoundException e)
catch (RateLimitExceededException e)
{
Logger.LogWarning(e, "Not found exception while retrieving upstream repository info!");
return RateLimit<AdministrationResponse>(e);
}
catch (ApiException e)
{
Logger.LogWarning(e, OctokitException);
return new AuthorityResponse<AdministrationResponse>(
new ErrorMessageResponse(ErrorCode.RemoteApiError)
{
AdditionalData = e.Message,
},
HttpFailureResponse.FailedDependency);
}
});
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<ServerUpdateResponse>> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(targetVersion);
return new(
() =>
{
if (uploadZip)
return Flag(AdministrationRights.UploadVersion);
return Flag(AdministrationRights.ChangeVersion);
},
async () =>
{
if (targetVersion.Major < 4)
return BadRequest<ServerUpdateResponse>(ErrorCode.CannotChangeServerSuite);
if (!serverControl.WatchdogPresent)
return new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.MissingHostWatchdog),
HttpFailureResponse.UnprocessableEntity);
IFileUploadTicket? uploadTicket = uploadZip
? fileTransferService.CreateUpload(FileUploadStreamKind.None)
: null;
ServerUpdateResult updateResult;
try
{
try
{
updateResult = await serverUpdateInitiator.InitiateUpdate(uploadTicket, targetVersion, cancellationToken);
}
catch
{
if (uploadZip)
await uploadTicket!.DisposeAsync();
throw;
}
}
catch (RateLimitExceededException ex)
{
return RateLimit<ServerUpdateResponse>(ex);
}
catch (ApiException e)
{
Logger.LogWarning(e, OctokitException);
return new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.RemoteApiError)
{
AdditionalData = e.Message,
},
HttpFailureResponse.FailedDependency);
}
return new AdministrationResponse
return updateResult switch
{
LatestVersion = greatestVersion,
TrackedRepositoryUrl = repoUrl,
GeneratedAt = DateTimeOffset.UtcNow,
ServerUpdateResult.Started => new AuthorityResponse<ServerUpdateResponse>(new ServerUpdateResponse(targetVersion, uploadTicket?.Ticket.FileTicket), HttpSuccessResponse.Accepted),
ServerUpdateResult.ReleaseMissing => Gone<ServerUpdateResponse>(),
ServerUpdateResult.UpdateInProgress => BadRequest<ServerUpdateResponse>(ErrorCode.ServerUpdateInProgress),
ServerUpdateResult.SwarmIntegrityCheckFailed => new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.SwarmIntegrityCheckFailed),
HttpFailureResponse.FailedDependency),
_ => throw new InvalidOperationException($"Unexpected ServerUpdateResult: {updateResult}"),
};
}
var ttl = TimeSpan.FromMinutes(30);
Task<AdministrationResponse> task;
if (forceFresh || !cacheService.TryGetValue(ReadCacheKey, out var rawCacheObject))
{
using var entry = cacheService.CreateEntry(ReadCacheKey);
entry.AbsoluteExpirationRelativeToNow = ttl;
entry.Value = task = CacheFactory();
}
else
task = (Task<AdministrationResponse>)rawCacheObject!;
var result = await task.WaitAsync(cancellationToken);
return new AuthorityResponse<AdministrationResponse>(result);
}
catch (RateLimitExceededException e)
{
return RateLimit<AdministrationResponse>(e);
}
catch (ApiException e)
{
Logger.LogWarning(e, OctokitException);
return new AuthorityResponse<AdministrationResponse>(
new ErrorMessageResponse(ErrorCode.RemoteApiError)
{
AdditionalData = e.Message,
},
HttpFailureResponse.FailedDependency);
}
});
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<ServerUpdateResponse>> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken)
{
var attemptingUpload = uploadZip == true;
if (attemptingUpload)
{
if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.UploadVersion))
return Forbid<ServerUpdateResponse>();
}
else if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.ChangeVersion))
return Forbid<ServerUpdateResponse>();
if (targetVersion.Major < 4)
return BadRequest<ServerUpdateResponse>(ErrorCode.CannotChangeServerSuite);
if (!serverControl.WatchdogPresent)
return new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.MissingHostWatchdog),
HttpFailureResponse.UnprocessableEntity);
IFileUploadTicket? uploadTicket = attemptingUpload
? fileTransferService.CreateUpload(FileUploadStreamKind.None)
: null;
ServerUpdateResult updateResult;
try
{
try
public RequirementsGated<AuthorityResponse> TriggerServerRestart()
=> new(
() => Flag(AdministrationRights.RestartHost),
async () =>
{
updateResult = await serverUpdateInitiator.InitiateUpdate(uploadTicket, targetVersion, cancellationToken);
}
catch
{
if (attemptingUpload)
await uploadTicket!.DisposeAsync();
throw;
}
}
catch (RateLimitExceededException ex)
{
return RateLimit<ServerUpdateResponse>(ex);
}
catch (ApiException e)
{
Logger.LogWarning(e, OctokitException);
return new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.RemoteApiError)
if (!serverControl.WatchdogPresent)
{
AdditionalData = e.Message,
},
HttpFailureResponse.FailedDependency);
}
Logger.LogDebug("Restart request failed due to lack of host watchdog!");
return new AuthorityResponse(
new ErrorMessageResponse(ErrorCode.MissingHostWatchdog),
HttpFailureResponse.UnprocessableEntity);
}
return updateResult switch
{
ServerUpdateResult.Started => new AuthorityResponse<ServerUpdateResponse>(new ServerUpdateResponse(targetVersion, uploadTicket?.Ticket.FileTicket), HttpSuccessResponse.Accepted),
ServerUpdateResult.ReleaseMissing => Gone<ServerUpdateResponse>(),
ServerUpdateResult.UpdateInProgress => BadRequest<ServerUpdateResponse>(ErrorCode.ServerUpdateInProgress),
ServerUpdateResult.SwarmIntegrityCheckFailed => new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.SwarmIntegrityCheckFailed),
HttpFailureResponse.FailedDependency),
_ => throw new InvalidOperationException($"Unexpected ServerUpdateResult: {updateResult}"),
};
}
await serverControl.Restart();
return new AuthorityResponse();
});
/// <inheritdoc />
public async ValueTask<AuthorityResponse> TriggerServerRestart()
public RequirementsGated<AuthorityResponse<LogFileResponse>> GetLog(string path, CancellationToken cancellationToken)
{
if (!serverControl.WatchdogPresent)
{
Logger.LogDebug("Restart request failed due to lack of host watchdog!");
return new AuthorityResponse(
new ErrorMessageResponse(ErrorCode.MissingHostWatchdog),
HttpFailureResponse.UnprocessableEntity);
}
ArgumentNullException.ThrowIfNull(path);
return new(
() => Flag(AdministrationRights.DownloadLogs),
async () =>
{
path = HttpUtility.UrlDecode(path);
await serverControl.Restart();
return new AuthorityResponse();
// guard against directory navigation
var sanitizedPath = ioManager.GetFileName(path);
if (path != sanitizedPath)
return Forbid<LogFileResponse>();
var fullPath = ioManager.ConcatPath(
fileLoggingConfigurationOptions.Value.GetFullLogDirectory(ioManager, assemblyInformationProvider, platformIdentifier),
path);
try
{
var fileTransferTicket = fileTransferService.CreateDownload(
new FileDownloadProvider(
() => null,
null,
fullPath,
true));
return new AuthorityResponse<LogFileResponse>(new LogFileResponse
{
Name = path,
LastModified = await ioManager.GetLastModified(fullPath, cancellationToken),
FileTicket = fileTransferTicket.FileTicket,
});
}
catch (IOException ex)
{
return Conflict<LogFileResponse>(ErrorCode.IOError, ex.ToString());
}
});
}
}
}
@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Components;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Models;
namespace Tgstation.Server.Host.Authority
{
/// <inheritdoc cref="IChatAuthority" />
sealed class ChatAuthority : ComponentInterfacingAuthorityBase, IChatAuthority
{
/// <summary>
/// Initializes a new instance of the <see cref="ChatAuthority"/> class.
/// </summary>
/// <param name="instanceManager">The <see cref="IInstanceManager"/> for the <see cref="ChatAuthority"/>.</param>
/// <param name="databaseContext">The <see cref="AuthorityBase.DatabaseContext"/>.</param>
/// <param name="logger">The <see cref="AuthorityBase.Logger"/>.</param>
public ChatAuthority(IInstanceManager instanceManager, IDatabaseContext databaseContext, ILogger<ChatAuthority> logger)
: base(instanceManager, databaseContext, logger)
{
}
/// <summary>
/// Perform some basic validation of a given <paramref name="model"/>.
/// </summary>
/// <param name="model">The <see cref="ChatBotApiBase"/> to validate.</param>
/// <param name="forCreation">If the <paramref name="model"/> is being created.</param>
/// <returns>An <see cref="BadRequestObjectResult"/> to respond with or <see langword="null"/>.</returns>
static AuthorityResponse<ChatBot>? StandardModelChecks(ChatBot model, bool forCreation)
{
if (model.ReconnectionInterval == 0)
throw new InvalidOperationException("RecconnectionInterval cannot be zero!");
if (model.Name != null && String.IsNullOrWhiteSpace(model.Name))
return BadRequest<ChatBot>(ErrorCode.ChatBotWhitespaceName);
if (model.ConnectionString != null && String.IsNullOrWhiteSpace(model.ConnectionString))
return BadRequest<ChatBot>(ErrorCode.ChatBotWhitespaceConnectionString);
var defaultMaxChannels = (ulong)Math.Max(ChatBot.DefaultChannelLimit, model.Channels?.Count ?? 0);
if (defaultMaxChannels > UInt16.MaxValue)
return BadRequest<ChatBot>(ErrorCode.ChatBotMaxChannels);
if (forCreation)
model.ChannelLimit ??= (ushort)defaultMaxChannels;
return null;
}
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<ChatBot>> Create(
IEnumerable<Models.ChatChannel> initialChannels,
string name,
string connectionString,
ChatProvider provider,
long instanceId,
uint? reconnectionInterval,
ushort? channelLimit,
bool enabled,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(initialChannels);
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(connectionString);
return new(
() => Flag(ChatBotRights.Create),
async () =>
{
var model = new ChatBot
{
Name = name,
ConnectionString = connectionString,
Enabled = enabled,
InstanceId = instanceId,
Provider = provider,
ReconnectionInterval = reconnectionInterval,
ChannelLimit = channelLimit,
Channels = initialChannels.ToList(),
};
var earlyOut = StandardModelChecks(model, true);
if (earlyOut != null)
return earlyOut;
var query = await DatabaseContext
.Instances
.Where(instance => instance.Id == instanceId)
.Select(instance => new
{
ChatBotLimit = instance.ChatBotLimit!.Value,
TotalChatBots = instance.ChatSettings!.Count,
})
.FirstOrDefaultAsync(cancellationToken);
if (query == null)
return Gone<ChatBot>();
if (query.TotalChatBots >= query.ChatBotLimit)
return Conflict<ChatBot>(ErrorCode.ChatBotMax);
model.Enabled ??= false;
model.ReconnectionInterval ??= 1;
DatabaseContext.ChatBots.Add(model);
await DatabaseContext.Save(cancellationToken);
return await WithComponentInstance(
async instance =>
{
try
{
// try to create it
await instance.Chat.ChangeSettings(model, cancellationToken);
if (model.Channels.Count > 0)
await instance.Chat.ChangeChannels(model.Require(x => x.Id), model.Channels, cancellationToken);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to complete chat bot {id} initialization after addition, removing...", model.Id);
// undo the add
DatabaseContext.ChatBots.Remove(model);
// DCTx2: Operations must always run
await DatabaseContext.Save(default);
await instance.Chat.DeleteConnection(model.Require(x => x.Id), default);
throw;
}
return new AuthorityResponse<ChatBot>(model, HttpSuccessResponse.Created);
},
instanceId);
},
instanceId);
}
}
}
@@ -8,7 +8,7 @@ using Octokit;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Security.RightsEvaluation;
namespace Tgstation.Server.Host.Authority.Core
{
@@ -17,11 +17,6 @@ namespace Tgstation.Server.Host.Authority.Core
/// </summary>
abstract class AuthorityBase : IAuthority
{
/// <summary>
/// Gets the <see cref="IAuthenticationContext"/> for the <see cref="AuthorityBase"/>.
/// </summary>
protected IAuthenticationContext AuthenticationContext { get; }
/// <summary>
/// Gets the <see cref="IDatabaseContext"/> for the <see cref="AuthorityBase"/>.
/// </summary>
@@ -88,24 +83,57 @@ namespace Tgstation.Server.Host.Authority.Core
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> of the <see cref="AuthorityResponse{TResult}.Result"/>.</typeparam>
/// <param name="errorCode">The <see cref="ErrorCode"/>.</param>
/// <param name="additionalData"><see cref="ErrorMessageResponse.AdditionalData"/> for the error message.</param>
/// <returns>A new, errored <see cref="AuthorityResponse{TResult}"/>.</returns>
protected static AuthorityResponse<TResult> Conflict<TResult>(ErrorCode errorCode)
protected static AuthorityResponse<TResult> Conflict<TResult>(ErrorCode errorCode, string? additionalData = null)
=> new(
new ErrorMessageResponse(errorCode),
new ErrorMessageResponse(errorCode)
{
AdditionalData = additionalData,
},
HttpFailureResponse.Conflict);
/// <summary>
/// Helper to quickly construct a <see cref="FlagRightsConditional{TRights}"/>.
/// </summary>
/// <typeparam name="TRights">The <typeparamref name="TRights"/> to evaluate.</typeparam>
/// <param name="flag">The single bit flag of the <typeparamref name="TRights"/>.</param>
/// <returns>A new <see cref="FlagRightsConditional{TRights}"/>.</returns>
protected static FlagRightsConditional<TRights> Flag<TRights>(TRights flag)
where TRights : Enum
=> new(flag);
/// <summary>
/// Helper to quickly construct an <see cref="OrRightsConditional{TRights}"/>.
/// </summary>
/// <typeparam name="TRights">The <typeparamref name="TRights"/> to evaluate.</typeparam>
/// <param name="lhs">The left hand side operand.</param>
/// <param name="rhs">The right hand side operand.</param>
/// <returns>A new <see cref="OrRightsConditional{TRights}"/>.</returns>
protected static OrRightsConditional<TRights> Or<TRights>(RightsConditional<TRights> lhs, RightsConditional<TRights> rhs)
where TRights : Enum
=> new(lhs, rhs);
/// <summary>
/// Helper to quickly construct an <see cref="AndRightsConditional{TRights}"/>.
/// </summary>
/// <typeparam name="TRights">The <typeparamref name="TRights"/> to evaluate.</typeparam>
/// <param name="lhs">The left hand side operand.</param>
/// <param name="rhs">The right hand side operand.</param>
/// <returns>A new <see cref="AndRightsConditional{TRights}"/>.</returns>
protected static AndRightsConditional<TRights> And<TRights>(RightsConditional<TRights> lhs, RightsConditional<TRights> rhs)
where TRights : Enum
=> new(lhs, rhs);
/// <summary>
/// Initializes a new instance of the <see cref="AuthorityBase"/> class.
/// </summary>
/// <param name="authenticationContext">The value of <see cref="AuthenticationContext"/>.</param>
/// <param name="databaseContext">The value of <see cref="DatabaseContext"/>.</param>
/// <param name="logger">The value of <see cref="Logger"/>.</param>
protected AuthorityBase(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<AuthorityBase> logger)
{
AuthenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext));
DatabaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -1,7 +1,8 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
using Microsoft.AspNetCore.Authorization;
namespace Tgstation.Server.Host.Authority.Core
{
@@ -14,35 +15,60 @@ namespace Tgstation.Server.Host.Authority.Core
/// </summary>
protected TAuthority Authority { get; }
/// <summary>
/// The authorization service for the <see cref="AuthorityInvokerBase{TAuthority}"/>.
/// </summary>
readonly Security.IAuthorizationService authorizationService;
/// <summary>
/// Initializes a new instance of the <see cref="AuthorityInvokerBase{TAuthority}"/> class.
/// </summary>
/// <param name="authority">The value of <see cref="Authority"/>.</param>
public AuthorityInvokerBase(TAuthority authority)
/// <param name="authorizationService">The value of <see cref="authorizationService"/>.</param>
public AuthorityInvokerBase(
TAuthority authority,
Security.IAuthorizationService authorizationService)
{
Authority = authority ?? throw new ArgumentNullException(nameof(authority));
this.authorizationService = authorizationService ?? throw new ArgumentNullException(nameof(authorizationService));
}
/// <inheritdoc />
IQueryable<TResult> IAuthorityInvoker<TAuthority>.InvokeQueryable<TResult>(Func<TAuthority, IQueryable<TResult>> authorityInvoker)
async ValueTask<IQueryable<TResult>?> IAuthorityInvoker<TAuthority>.InvokeQueryable<TResult>(Func<TAuthority, RequirementsGated<IQueryable<TResult>>> authorityInvoker)
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
return authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
return await ExecuteIfRequirementsSatisfied(requirementsGate);
}
/// <inheritdoc />
IQueryable<TApiModel> IAuthorityInvoker<TAuthority>.InvokeTransformableQueryable<TResult, TApiModel, TTransformer>(Func<TAuthority, IQueryable<TResult>> authorityInvoker)
/// <summary>
/// Unwrap a <see cref="RequirementsGated{TResult}"/> result, returning <see langword="null"/> if the requirements weren't satisfied.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> contained by the <paramref name="requirementsGate"/>.</typeparam>
/// <param name="requirementsGate">The <see cref="RequirementsGated{TResult}"/> result.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TResult"/> if the requirements were met, <see langword="null"/> if the requirments weren't met.</returns>
protected async ValueTask<TResult?> ExecuteIfRequirementsSatisfied<TResult>(RequirementsGated<TResult> requirementsGate)
where TResult : class
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
var requirements = await requirementsGate.GetRequirements();
var authorizationResult = await authorizationService.AuthorizeAsync(requirements, requirementsGate.InstanceId);
var queryable = authorityInvoker(Authority);
if (!authorizationResult.Succeeded)
{
OnRequirementsFailure(authorizationResult.Failure);
return null;
}
if (typeof(EntityId).IsAssignableFrom(typeof(TResult)))
queryable = queryable.OrderBy(item => ((EntityId)(object)item).Id!.Value); // order by ID to fix an EFCore warning
return await requirementsGate.Execute(authorizationService);
}
var expression = new TTransformer().Expression;
return queryable
.Select(expression);
/// <summary>
/// Called to handle generic behavior when requirements evaluation fails.
/// </summary>
/// <param name="authFailure">The <see cref="AuthorizationFailure"/>.</param>
protected virtual void OnRequirementsFailure(AuthorizationFailure authFailure)
{
}
}
}
@@ -1,5 +1,6 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using Microsoft.AspNetCore.Mvc;
using Tgstation.Server.Api.Models.Response;
@@ -12,6 +13,11 @@ namespace Tgstation.Server.Host.Authority.Core
/// <typeparam name="TResult">The <see cref="Type"/> of success response.</typeparam>
public sealed class AuthorityResponse<TResult> : AuthorityResponse
{
/// <summary>
/// An expression to convert a <typeparamref name="TResult"/> into an <see cref="AuthorityResponse{TResult}"/>. The resulting <see cref="AuthorityResponse{TResult}"/> MUST NOT be used.
/// </summary>
public static Expression<Func<TResult, AuthorityResponse<TResult>>> MappingExpression { get; }
/// <inheritdoc />
[MemberNotNullWhen(true, nameof(IsNoContent))]
public override bool Success => base.Success;
@@ -26,13 +32,24 @@ namespace Tgstation.Server.Host.Authority.Core
/// <summary>
/// The success <typeparamref name="TResult"/>.
/// </summary>
public TResult? Result { get; }
public TResult? Result { get; private init; }
/// <summary>
/// The <see cref="HttpSuccessResponse"/> for generating the <see cref="IActionResult"/>s.
/// </summary>
public HttpSuccessResponse? SuccessResponse { get; }
/// <summary>
/// Initializes static members of the <see cref="AuthorityResponse{TResult}"/> class.
/// </summary>
static AuthorityResponse()
{
MappingExpression = result => new AuthorityResponse<TResult>
{
Result = result,
};
}
/// <summary>
/// Initializes a new instance of the <see cref="AuthorityResponse{TResult}"/> class.
/// </summary>
@@ -0,0 +1,60 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Serilog.Context;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Components;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Utils;
namespace Tgstation.Server.Host.Authority.Core
{
/// <summary>
/// <see cref="AuthorityBase"/> for <see cref="IAuthority"/>s that need to access <see cref="IInstanceCore"/>s.
/// </summary>
abstract class ComponentInterfacingAuthorityBase : AuthorityBase
{
/// <summary>
/// The <see cref="IInstanceManager"/> for the <see cref="ComponentInterfacingAuthorityBase"/>.
/// </summary>
readonly IInstanceManager instanceManager;
/// <summary>
/// Initializes a new instance of the <see cref="ComponentInterfacingAuthorityBase"/> class.
/// </summary>
/// <param name="instanceManager">The value of <see cref="instanceManager"/>.</param>
/// <param name="databaseContext">The <see cref="AuthorityBase.DatabaseContext"/>.</param>
/// <param name="logger">The <see cref="AuthorityBase.Logger"/>.</param>
protected ComponentInterfacingAuthorityBase(
IInstanceManager instanceManager,
IDatabaseContext databaseContext,
ILogger<ComponentInterfacingAuthorityBase> logger)
: base(databaseContext, logger)
{
this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager));
}
/// <summary>
/// Run a given <paramref name="action"/> with the relevant <see cref="IInstance"/>.
/// </summary>
/// <typeparam name="TResult">The type of result the returned <see cref="AuthorityResponse{TResult}"/> uses.</typeparam>
/// <param name="action">A <see cref="Func{T, TResult}"/> accepting the <see cref="IInstance"/> and returning a <see cref="ValueTask{TResult}"/> with the <see cref="IActionResult"/>.</param>
/// <param name="instanceId">The <see cref="EntityId.Id"/> of <see cref="Models.Instance"/> to grab.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/> that should be returned.</returns>
/// <remarks>The context of <paramref name="action"/> should be as small as possible so as to avoid race conditions. This function can return a <see cref="ConflictResult"/> if the requested instance was offline.</remarks>
protected async ValueTask<AuthorityResponse<TResult>> WithComponentInstance<TResult>(Func<IInstanceCore, ValueTask<AuthorityResponse<TResult>>> action, long instanceId)
{
using var instanceReference = instanceManager.GetInstanceReference(instanceId);
using (LogContext.PushProperty(SerilogContextHelper.InstanceReferenceContextProperty, instanceReference?.Uid))
{
if (instanceReference == null)
return Conflict<TResult>(ErrorCode.InstanceOffline);
return await action(instanceReference);
}
}
}
}
@@ -1,6 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using GreenDonut.Data;
using HotChocolate;
using Microsoft.AspNetCore.Authorization;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Common.Extensions;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.GraphQL;
namespace Tgstation.Server.Host.Authority.Core
@@ -12,14 +23,17 @@ namespace Tgstation.Server.Host.Authority.Core
/// <summary>
/// Throws a <see cref="ErrorMessageException"/> for errored <paramref name="authorityResponse"/>s.
/// </summary>
/// <param name="authorityResponse">The potentially errored <paramref name="authorityResponse"/>.</param>
/// <typeparam name="TAuthorityResponse">The <see cref="AuthorityResponse"/> <see cref="Type"/> being checked.</typeparam>
/// <param name="authorityResponse">The potentially errored <paramref name="authorityResponse"/> or <see langword="null"/> if requirements evaluation failed.</param>
/// <param name="errorOnMissing">If an error should be raised for <see cref="HttpFailureResponse.NotFound"/> and <see cref="HttpFailureResponse.Gone"/> failures.</param>
static void ThrowGraphQLErrorIfNecessary(AuthorityResponse authorityResponse, bool errorOnMissing)
/// <returns><paramref name="authorityResponse"/> if an <see cref="ErrorMessageException"/> wasn't thrown.</returns>
public static TAuthorityResponse ThrowGraphQLErrorIfNecessary<TAuthorityResponse>(TAuthorityResponse authorityResponse, bool errorOnMissing)
where TAuthorityResponse : AuthorityResponse
{
if (authorityResponse.Success
|| ((authorityResponse.FailureResponse.Value == HttpFailureResponse.NotFound
|| authorityResponse.FailureResponse.Value == HttpFailureResponse.Gone) && !errorOnMissing))
return;
return authorityResponse;
var fallbackString = authorityResponse.FailureResponse.ToString()!;
throw new ErrorMessageException(authorityResponse.ErrorMessage, fallbackString);
@@ -29,38 +43,43 @@ namespace Tgstation.Server.Host.Authority.Core
/// Initializes a new instance of the <see cref="GraphQLAuthorityInvoker{TAuthority}"/> class.
/// </summary>
/// <param name="authority">The <typeparamref name="TAuthority"/>.</param>
public GraphQLAuthorityInvoker(TAuthority authority)
: base(authority)
/// <param name="authorizationService">the authorization service to use.</param>
public GraphQLAuthorityInvoker(TAuthority authority, Security.IAuthorizationService authorizationService)
: base(authority, authorizationService)
{
}
/// <inheritdoc />
async ValueTask IGraphQLAuthorityInvoker<TAuthority>.Invoke(Func<TAuthority, ValueTask<AuthorityResponse>> authorityInvoker)
async ValueTask IGraphQLAuthorityInvoker<TAuthority>.Invoke(Func<TAuthority, RequirementsGated<AuthorityResponse>> authorityInvoker)
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
ThrowGraphQLErrorIfNecessary(authorityResponse, true);
}
/// <inheritdoc />
async ValueTask<TApiModel?> IGraphQLAuthorityInvoker<TAuthority>.InvokeAllowMissing<TResult, TApiModel>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
async ValueTask<TApiModel?> IGraphQLAuthorityInvoker<TAuthority>.InvokeAllowMissing<TResult, TApiModel>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TApiModel : default
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
ThrowGraphQLErrorIfNecessary(authorityResponse, false);
return authorityResponse.Result;
}
/// <inheritdoc />
async ValueTask<TApiModel?> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
async ValueTask<TApiModel?> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TApiModel : default
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
ThrowGraphQLErrorIfNecessary(authorityResponse, false);
var result = authorityResponse.Result;
if (result == null)
@@ -70,11 +89,78 @@ namespace Tgstation.Server.Host.Authority.Core
}
/// <inheritdoc />
ValueTask<TApiModel> IGraphQLAuthorityInvoker<TAuthority>.Invoke<TResult, TApiModel>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
=> ((IGraphQLAuthorityInvoker<TAuthority>)this).InvokeAllowMissing<TResult, TApiModel>(authorityInvoker)!;
async ValueTask<IQueryable<TApiModel>> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformableQueryable<TResult, TApiModel, TTransformer>(
Func<TAuthority, RequirementsGated<IQueryable<TResult>>> authorityInvoker,
Func<IQueryable<TResult>, IQueryable<TResult>>? preTransformer)
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
var requirementsGate = authorityInvoker(Authority);
var queryable = await ExecuteIfRequirementsSatisfied(requirementsGate);
if (preTransformer != null)
queryable = preTransformer(queryable);
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);
}
/// <inheritdoc />
ValueTask<TApiModel> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformable<TResult, TApiModel, TTransformer>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
=> ((IGraphQLAuthorityInvoker<TAuthority>)this).InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(authorityInvoker)!;
async ValueTask<Dictionary<long, AuthorityResponse<TApiModel>>> IGraphQLAuthorityInvoker<TAuthority>.ExecuteDataLoader<TResult, TApiModel, TTransformer>(
Func<TAuthority, long, RequirementsGated<Projectable<TResult, TApiModel>>> authorityInvoker,
IReadOnlyList<long> ids,
QueryContext<AuthorityResponse<TApiModel>>? queryContext)
{
ArgumentNullException.ThrowIfNull(ids);
var resultsTask = ValueTaskExtensions.WhenAll(
ids.Select(
id =>
{
var requirementsGate = authorityInvoker(Authority, id);
return ExecuteIfRequirementsSatisfied(requirementsGate);
}),
ids.Count);
var unwrappedContext = queryContext?.AuthorityResponseUnwrap();
var results = await resultsTask;
var responses = await Projectable<TResult, TApiModel>.Combine(
queryable => queryable
.Select(new TTransformer().ProjectedExpression)
.With(unwrappedContext),
results);
return responses;
}
/// <inheritdoc />
async ValueTask<TApiModel> IGraphQLAuthorityInvoker<TAuthority>.Invoke<TResult, TApiModel>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
=> await ((IGraphQLAuthorityInvoker<TAuthority>)this).InvokeAllowMissing<TResult, TApiModel>(authorityInvoker)
?? throw new InvalidOperationException("Authority invocation should have returned a non-nullable result!");
/// <inheritdoc />
async ValueTask<TApiModel> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformable<TResult, TApiModel, TTransformer>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
=> await ((IGraphQLAuthorityInvoker<TAuthority>)this).InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(authorityInvoker)
?? throw new InvalidOperationException("Authority invocation should have returned a non-nullable result!");
/// <inheritdoc />
protected override void OnRequirementsFailure(AuthorizationFailure authFailure)
=> throw authFailure.ForbiddenGraphQLException();
/// <summary>
/// Unwrap a <see cref="RequirementsGated{TResult}"/> result, throwing a <see cref="GraphQLException"/> if they weren't met.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> contained by the <paramref name="requirementsGate"/>.</typeparam>
/// <param name="requirementsGate">The <see cref="RequirementsGated{TResult}"/> result.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TResult"/> if the requirements were met.</returns>
/// <exception cref="GraphQLException">Throw when requirements were not met.</exception>
new async ValueTask<TResult> ExecuteIfRequirementsSatisfied<TResult>(RequirementsGated<TResult> requirementsGate)
where TResult : class
=> (await base.ExecuteIfRequirementsSatisfied(requirementsGate))!; // base class throws if requirements evaluation fails
}
}
@@ -1,7 +1,6 @@
using System;
using System.Linq;
using Tgstation.Server.Host.Models;
using System.Threading.Tasks;
namespace Tgstation.Server.Host.Authority.Core
{
@@ -16,21 +15,8 @@ namespace Tgstation.Server.Host.Authority.Core
/// Invoke a <typeparamref name="TAuthority"/> method and get the result.
/// </summary>
/// <typeparam name="TResult">The returned <see cref="Type"/>.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="IQueryable{T}"/> <typeparamref name="TResult"/>.</param>
/// <returns>A <see cref="IQueryable{T}"/> <typeparamref name="TResult"/> returned.</returns>
IQueryable<TResult> InvokeQueryable<TResult>(Func<TAuthority, IQueryable<TResult>> authorityInvoker);
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the transformed result.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> returned by the <typeparamref name="TAuthority"/>.</typeparam>
/// <typeparam name="TApiModel">The returned <see cref="Type"/>.</typeparam>
/// <typeparam name="TTransformer">The <see cref="ITransformer{TInput, TOutput}"/> for converting <typeparamref name="TResult"/>s to <typeparamref name="TApiModel"/>s.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="IQueryable{T}"/> <typeparamref name="TResult"/>.</param>
/// <returns>A <see cref="IQueryable{T}"/> <typeparamref name="TResult"/> returned.</returns>
IQueryable<TApiModel> InvokeTransformableQueryable<TResult, TApiModel, TTransformer>(Func<TAuthority, IQueryable<TResult>> authorityInvoker)
where TResult : IApiTransformable<TResult, TApiModel, TTransformer>
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
/// <param name="authorityInvoker">The authority invocation returning a <see cref="IQueryable{T}"/> <typeparamref name="TResult"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IQueryable{T}"/> <typeparamref name="TResult"/> returned on success or <see langword="null"/> if the requirements weren't satisfied.</returns>
ValueTask<IQueryable<TResult>?> InvokeQueryable<TResult>(Func<TAuthority, RequirementsGated<IQueryable<TResult>>> authorityInvoker);
}
}
@@ -0,0 +1,220 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Tgstation.Server.Api.Models;
namespace Tgstation.Server.Host.Authority.Core
{
/// <summary>
/// A projectable <typeparamref name="TResult"/> based on an underlying <typeparamref name="TQueried"/>.
/// </summary>
/// <typeparam name="TQueried">The <see cref="EntityId"/> model <see cref="Type"/> queried.</typeparam>
/// <typeparam name="TResult">The transformed result <see cref="Type"/>.</typeparam>
public sealed class Projectable<TQueried, TResult>
where TQueried : EntityId
where TResult : notnull
{
/// <summary>
/// The underlying <see cref="IQueryable{T}"/>. Should only select one entity.
/// </summary>
readonly IQueryable<TQueried> query;
/// <summary>
/// The selector for the <see cref="ProjectedPair{TQueried, TResult}"/> data required by the <see cref="resultMapper"/>.
/// </summary>
readonly Expression<Func<ProjectedPair<TQueried, TResult>, ProjectedPair<object?, TResult>>> selector;
/// <summary>
/// Mapper for transforming the <see cref="ProjectedPair{TQueried, TResult}"/> <typeparamref name="TResult"/> into an <see cref="AuthorityResponse{TResult}"/>. Called with the output of <see cref="selector"/> as <see cref="ProjectedPair{TQueried, TResult}.Queried"/>.
/// </summary>
readonly Func<ProjectedPair<object?, TResult>?, AuthorityResponse<TResult>> resultMapper;
/// <summary>
/// <see cref="CancellationToken"/> for the operation.
/// </summary>
readonly CancellationToken cancellationToken;
/// <summary>
/// Combine a set of <see cref="Projectable{TQueried, TResult}"/>s into the resulting <see cref="AuthorityResponse{TResult}"/>s.
/// </summary>
/// <param name="projection">The projection used to <see cref="Resolve(Func{IQueryable{TQueried}, IQueryable{ProjectedPair{TQueried, TResult}}})"/> each of the <paramref name="inputs"/>.</param>
/// <param name="inputs">The <see cref="Projectable{TQueried, TResult}"/>s to combine.</param>
/// <returns>A <see cref="Dictionary{TKey, TValue}"/> of the resulting <see cref="AuthorityResponse{TResult}"/>s keyed by their <see cref="EntityId.Id"/>.</returns>
public static async ValueTask<Dictionary<long, AuthorityResponse<TResult>>> Combine(Func<IQueryable<TQueried>, IQueryable<ProjectedPair<TQueried, TResult>>> projection, params Projectable<TQueried, TResult>[] inputs)
{
ArgumentNullException.ThrowIfNull(projection);
ArgumentNullException.ThrowIfNull(inputs);
if (inputs.Length == 0)
return new Dictionary<long, AuthorityResponse<TResult>>();
var firstProjectable = inputs[0];
var workingSet = projection(firstProjectable.query);
foreach (var projectable in inputs.Skip(1))
{
if (firstProjectable.resultMapper != projectable.resultMapper)
throw new InvalidOperationException($"Different implementations of {nameof(resultMapper)} in combined {firstProjectable.GetType().Name}.");
if (firstProjectable.selector != projectable.selector)
throw new InvalidOperationException($"Different implementations of {nameof(selector)} in combined {firstProjectable.GetType().Name}.");
workingSet = workingSet.Union(projection(projectable.query));
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(inputs.Select(projectable => projectable.cancellationToken).ToArray());
var finalQueryable = workingSet
.Select(CombinedProjectionExpression(firstProjectable.selector));
return await finalQueryable
.ToDictionaryAsync(
result => result.Id,
result => firstProjectable.resultMapper(result.Projected));
}
/// <summary>
/// Create a <see cref="Projectable{TQueried, TResult}"/>.
/// </summary>
/// <param name="query">The underlying <see cref="IQueryable{T}"/>.</param>
/// <param name="resultMapper">A mapper for taking a new <see cref="ProjectedPair{TQueried, TResult}"/> (Who's <see cref="ProjectedPair{TQueried, TResult}.Queried"/> will be <see langword="null"/>) with its <see cref="ProjectedPair{TQueried, TResult}.Result"/> set amd converting it into an <see cref="AuthorityResponse{TResult}"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operations on <paramref name="query"/>.</param>
/// <returns>A new <see cref="Projectable{TQueried, TResult}"/>.</returns>
public static Projectable<TQueried, TResult> Create(
IQueryable<TQueried> query,
Func<ProjectedPair<object?, TResult>?, AuthorityResponse<TResult>> resultMapper,
CancellationToken cancellationToken)
=> Create(
query,
projected => new ProjectedPair<object?, TResult>
{
Queried = null,
Result = projected.Result,
},
resultMapper,
cancellationToken);
/// <summary>
/// Create a <see cref="Projectable{TQueried, TResult}"/>.
/// </summary>
/// <typeparam name="TSelection">A <see cref="Type"/> selected out of <typeparamref name="TQueried"/> to be used in <paramref name="resultMapper"/>.</typeparam>
/// <param name="query">The underlying <see cref="IQueryable{T}"/>.</param>
/// <param name="selector">Selects a value to be set as the <see cref="ProjectedPair{TQueried, TResult}.Queried"/> value for later use in <paramref name="resultMapper"/>.</param>
/// <param name="resultMapper">A mapper for taking a new <see cref="ProjectedPair{TQueried, TResult}"/> (Who's <see cref="ProjectedPair{TQueried, TResult}.Queried"/> will be the result of <paramref name="selector"/> on the original <paramref name="query"/>.) with its <see cref="ProjectedPair{TQueried, TResult}.Result"/> set amd converting it into an <see cref="AuthorityResponse{TResult}"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operations on <paramref name="query"/>.</param>
/// <returns>A new <see cref="Projectable{TQueried, TResult}"/>.</returns>
public static Projectable<TQueried, TResult> Create<TSelection>(
IQueryable<TQueried> query,
Expression<Func<ProjectedPair<TQueried, TResult>, ProjectedPair<TSelection, TResult>>> selector,
Func<ProjectedPair<TSelection, TResult>?, AuthorityResponse<TResult>> resultMapper,
CancellationToken cancellationToken)
{
Expression<Func<ProjectedPair<TSelection, TResult>, ProjectedPair<object?, TResult>>> makeProjectedGeneric = projected => new ProjectedPair<object?, TResult>
{
Queried = projected.Queried,
Result = projected.Result,
};
var parameter = Expression.Parameter(typeof(ProjectedPair<TQueried, TResult>), "innerProjectedParam");
return new(
query,
Expression.Lambda<Func<ProjectedPair<TQueried, TResult>, ProjectedPair<object?, TResult>>>(
Expression.Invoke(
makeProjectedGeneric,
Expression.Invoke(
selector,
parameter)),
parameter),
projected => resultMapper(
projected != null
? new ProjectedPair<TSelection, TResult>
{
Queried = (TSelection)projected.Queried!,
Result = projected.Result,
}
: null),
cancellationToken);
}
/// <summary>
/// Create an <see cref="Expression{TDelegate}"/> to transform a <see cref="ProjectedPair{TQueried, TResult}"/> into a <see cref="CombinedProjection"/>.
/// </summary>
/// <param name="selector">The selector used for to get business data for the <see cref="resultMapper"/>.</param>
/// <returns>A new <see cref="Expression{TDelegate}"/> transforming a <see cref="ProjectedPair{TQueried, TResult}"/> into a <see cref="CombinedProjection"/>.</returns>
private static Expression<Func<ProjectedPair<TQueried, TResult>, CombinedProjection>> CombinedProjectionExpression(Expression<Func<ProjectedPair<TQueried, TResult>, ProjectedPair<object?, TResult>>> selector)
{
Expression<Func<ProjectedPair<TQueried, TResult>, long>> idSelector = projected => projected.Queried.Id!.Value;
var parameter = Expression.Parameter(typeof(ProjectedPair<TQueried, TResult>), "projectedParamForCombined");
var selection = Expression.Invoke(selector, parameter);
var id = Expression.Invoke(idSelector, parameter);
var ourType = typeof(CombinedProjection);
var memberInitExpr = Expression.MemberInit(
Expression.New(ourType),
Expression.Bind(
ourType.GetProperty(nameof(CombinedProjection.Id))!,
id),
Expression.Bind(
ourType.GetProperty(nameof(CombinedProjection.Projected))!,
selection));
var finalExpr = Expression.Lambda<Func<ProjectedPair<TQueried, TResult>, CombinedProjection>>(memberInitExpr, parameter);
return finalExpr;
}
/// <summary>
/// Initializes a new instance of the <see cref="Projectable{TQueried, TResult}"/> class.
/// </summary>
/// <param name="query">The value of <see cref="query"/>.</param>
/// <param name="selector">The value of <see cref="selector"/>.</param>
/// <param name="resultMapper">The value of <see cref="resultMapper"/>.</param>
/// <param name="cancellationToken">The value of <see cref="cancellationToken"/>.</param>
private Projectable(
IQueryable<TQueried> query,
Expression<Func<ProjectedPair<TQueried, TResult>, ProjectedPair<object?, TResult>>> selector,
Func<ProjectedPair<object?, TResult>?, AuthorityResponse<TResult>> resultMapper,
CancellationToken cancellationToken)
{
this.query = query ?? throw new ArgumentNullException(nameof(query));
this.selector = selector ?? throw new ArgumentNullException(nameof(selector));
this.resultMapper = resultMapper ?? throw new ArgumentNullException(nameof(resultMapper));
this.cancellationToken = cancellationToken;
}
/// <summary>
/// Resolve the <see cref="Projectable{TQueried, TResult}"/>.
/// </summary>
/// <param name="projection">The mapping from <typeparamref name="TQueried"/> to <typeparamref name="TResult"/>.</param>
/// <returns>The resolved <see cref="AuthorityResponse{TResult}"/>.</returns>
public async ValueTask<AuthorityResponse<TResult>> Resolve(Func<IQueryable<TQueried>, IQueryable<ProjectedPair<TQueried, TResult>>> projection)
{
ArgumentNullException.ThrowIfNull(projection);
var selection = await projection(query)
.Select(selector)
.FirstOrDefaultAsync(cancellationToken);
return resultMapper(selection);
}
/// <summary>
/// DTO for selecting the <see cref="Id"/> from <typeparamref name="TQueried"/> alongside the selection <see cref="ProjectedPair{TQueried, TResult}"/>.
/// </summary>
private class CombinedProjection
{
/// <summary>
/// The <see cref="EntityId.Id"/> of <typeparamref name="TQueried"/>.
/// </summary>
public required long Id { get; init; }
/// <summary>
/// The selection/<typeparamref name="TResult"/> <see cref="ProjectedPair{TQueried, TResult}"/>.
/// </summary>
public required ProjectedPair<object?, TResult> Projected { get; init; }
}
}
}
@@ -0,0 +1,20 @@
namespace Tgstation.Server.Host.Authority.Core
{
/// <summary>
/// DTO for moving database projected <see cref="object"/>s through the system.
/// </summary>
/// <typeparam name="TQueried">The originally queried <see cref="global::System.Type"/>.</typeparam>
/// <typeparam name="TResult">The output DTO <see cref="global::System.Type"/>.</typeparam>
public sealed class ProjectedPair<TQueried, TResult>
{
/// <summary>
/// The originally queried <typeparamref name="TQueried"/>.
/// </summary>
public required TQueried Queried { get; init; }
/// <summary>
/// The output DTO <typeparamref name="TResult"/>.
/// </summary>
public required TResult Result { get; init; }
}
}
@@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Authority.Core
{
/// <summary>
/// Evaluates a set of <see cref="IAuthorizationRequirement"/>s to be checked before executing a response.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> of object the response generates.</typeparam>
public sealed class RequirementsGated<TResult>
{
/// <summary>
/// <see cref="Api.Models.EntityId.Id"/> of the relevant instance.
/// </summary>
public long? InstanceId { get; }
/// <summary>
/// The <see cref="IAuthorizationRequirement"/> retrieval function. <see cref="UserSessionValidRequirement"/> is included automatically.
/// </summary>
readonly Func<ValueTask<IEnumerable<IAuthorizationRequirement>>> getRequirements;
/// <summary>
/// The response generation function.
/// </summary>
readonly Func<Security.IAuthorizationService, ValueTask<TResult>> getResponse;
/// <summary>
/// If the <see cref="UserSessionValidRequirement"/> should not be added.
/// </summary>
readonly bool doNotAddUserSessionValidRequirement;
/// <summary>
/// Convert a given <paramref name="result"/> into a <see cref="RequirementsGated{TResult}"/>.
/// </summary>
/// <param name="result">The <typeparamref name="TResult"/> to convert.</param>
/// <returns>A new <see cref="RequirementsGated{TResult}"/> based on <paramref name="result"/>.</returns>
public static RequirementsGated<TResult> FromResult(TResult result)
=> new(
() => (IAuthorizationRequirement?)null,
() => ValueTask.FromResult(result));
/// <summary>
/// Initializes a new instance of the <see cref="RequirementsGated{TResult}"/> class.
/// </summary>
/// <param name="getRequirement">The value of <see cref="getRequirements"/>. Resulting in a <see langword="null"/> value is eqivalent to returning an empty <see cref="IEnumerable{T}"/> of <see cref="IAuthorizationRequirement"/>s.</param>
/// <param name="getResponse">The value of <see cref="getResponse"/>.</param>
public RequirementsGated(
Func<ValueTask<IAuthorizationRequirement?>> getRequirement,
Func<ValueTask<TResult>> getResponse)
{
ArgumentNullException.ThrowIfNull(getRequirement);
ArgumentNullException.ThrowIfNull(getResponse);
getRequirements = async () =>
{
var requirement = await getRequirement();
if (requirement == null)
return Enumerable.Empty<IAuthorizationRequirement>();
return new List<IAuthorizationRequirement>
{
requirement,
};
};
this.getResponse = _ => getResponse();
}
/// <summary>
/// Initializes a new instance of the <see cref="RequirementsGated{TResult}"/> class.
/// </summary>
/// <param name="getRequirements">The value of <see cref="getRequirements"/>.</param>
/// <param name="getResponse">The value of <see cref="getResponse"/>.</param>
public RequirementsGated(
Func<IEnumerable<IAuthorizationRequirement>> getRequirements,
Func<ValueTask<TResult>> getResponse)
{
ArgumentNullException.ThrowIfNull(getRequirements);
ArgumentNullException.ThrowIfNull(getResponse);
this.getRequirements = () => ValueTask.FromResult(getRequirements());
this.getResponse = _ => getResponse();
}
/// <summary>
/// Initializes a new instance of the <see cref="RequirementsGated{TResult}"/> class.
/// </summary>
/// <param name="getRequirement">The value of <see cref="getRequirements"/>. Resulting in a <see langword="null"/> value is eqivalent to returning an empty <see cref="IEnumerable{T}"/> of <see cref="IAuthorizationRequirement"/>s.</param>
/// <param name="getResponse">The value of <see cref="getResponse"/>.</param>
/// <param name="instanceId">The value of <see cref="InstanceId"/>.</param>
/// <param name="doNotAddUserSessionValidRequirement">The value of <see cref="doNotAddUserSessionValidRequirement"/>.</param>
public RequirementsGated(
Func<IAuthorizationRequirement?> getRequirement,
Func<ValueTask<TResult>> getResponse,
long? instanceId = null,
bool doNotAddUserSessionValidRequirement = false)
{
ArgumentNullException.ThrowIfNull(getRequirement);
ArgumentNullException.ThrowIfNull(getResponse);
getRequirements = () =>
{
var requirement = getRequirement();
if (requirement == null)
return ValueTask.FromResult(Enumerable.Empty<IAuthorizationRequirement>());
return ValueTask.FromResult<IEnumerable<IAuthorizationRequirement>>(
new List<IAuthorizationRequirement>
{
requirement,
});
};
this.getResponse = _ => getResponse();
this.doNotAddUserSessionValidRequirement = doNotAddUserSessionValidRequirement;
InstanceId = instanceId;
}
/// <summary>
/// Initializes a new instance of the <see cref="RequirementsGated{TResult}"/> class.
/// </summary>
/// <param name="getRequirement">The value of <see cref="getRequirements"/>. Resulting in a <see langword="null"/> value is eqivalent to returning an empty <see cref="IEnumerable{T}"/> of <see cref="IAuthorizationRequirement"/>s.</param>
/// <param name="getResponse">The value of <see cref="getResponse"/>.</param>
public RequirementsGated(
Func<IAuthorizationRequirement?> getRequirement,
Func<Security.IAuthorizationService, ValueTask<TResult>> getResponse)
{
ArgumentNullException.ThrowIfNull(getRequirement);
getRequirements = () =>
{
var requirement = getRequirement();
if (requirement == null)
return ValueTask.FromResult(Enumerable.Empty<IAuthorizationRequirement>());
return ValueTask.FromResult<IEnumerable<IAuthorizationRequirement>>(
new List<IAuthorizationRequirement>
{
requirement,
});
};
this.getResponse = getResponse ?? throw new ArgumentNullException(nameof(getResponse));
}
/// <summary>
/// Evaluates the <see cref="IAuthorizationRequirement"/>s of the request.
/// </summary>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IAuthorizationRequirement"/>s for the request.</returns>
public async ValueTask<IEnumerable<IAuthorizationRequirement>> GetRequirements()
{
var requirements = await getRequirements();
if (!doNotAddUserSessionValidRequirement)
requirements = UserSessionValidRequirement.InstanceAsEnumerable.Concat(requirements);
return requirements;
}
/// <summary>
/// Executes the request.
/// </summary>
/// <param name="authorizationService">The authorization service to use.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the request <typeparamref name="TResult"/>.</returns>
public ValueTask<TResult> Execute(Security.IAuthorizationService authorizationService)
=> getResponse(authorizationService);
}
}
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
using Tgstation.Server.Host.Controllers;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Authority.Core
{
@@ -22,7 +23,10 @@ namespace Tgstation.Server.Host.Authority.Core
/// <returns>An <see cref="IActionResult"/> for the <paramref name="authorityResponse"/>.</returns>
/// <typeparam name="TResult">The result <see cref="Type"/> returned in the <paramref name="authorityResponse"/>.</typeparam>
/// <typeparam name="TApiModel">The REST API result model built from <paramref name="authorityResponse"/>.</typeparam>
static IActionResult CreateSuccessfulActionResult<TResult, TApiModel>(ApiController controller, Func<TResult, TApiModel> resultTransformer, AuthorityResponse<TResult> authorityResponse)
static IActionResult CreateSuccessfulActionResult<TResult, TApiModel>(
ApiController controller,
Func<TResult, TApiModel> resultTransformer,
AuthorityResponse<TResult> authorityResponse)
where TApiModel : notnull
{
if (authorityResponse.IsNoContent!.Value)
@@ -44,9 +48,14 @@ namespace Tgstation.Server.Host.Authority.Core
/// </summary>
/// <param name="controller">The <see cref="ApiController"/> to use.</param>
/// <param name="authorityResponse">The <see cref="AuthorityResponse"/>.</param>
/// <returns>An <see cref="IActionResult"/> if the <paramref name="authorityResponse"/> is not successful, <see langword="null"/> otherwise.</returns>
static IActionResult? CreateErroredActionResult(ApiController controller, AuthorityResponse authorityResponse)
/// <returns>An <see cref="IActionResult"/> if the <paramref name="authorityResponse"/> is not successful, <see langword="null"/> otherwise. If <see langword="null"/> is returned, <paramref name="authorityResponse"/> is not <see langword="null"/>.</returns>
static IActionResult? CreateErroredActionResult(
ApiController controller,
AuthorityResponse? authorityResponse)
{
if (authorityResponse == null)
return controller.Forbid();
if (authorityResponse.Success)
return null;
@@ -74,47 +83,51 @@ namespace Tgstation.Server.Host.Authority.Core
/// Initializes a new instance of the <see cref="RestAuthorityInvoker{TAuthority}"/> class.
/// </summary>
/// <param name="authority">The <typeparamref name="TAuthority"/>.</param>
public RestAuthorityInvoker(TAuthority authority)
: base(authority)
/// <param name="authorizationService">The <see cref="IAuthorizationService"/> to use.</param>
public RestAuthorityInvoker(TAuthority authority, IAuthorizationService authorizationService)
: base(authority, authorizationService)
{
}
/// <inheritdoc />
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.Invoke(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse>> authorityInvoker)
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.Invoke(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse>> authorityInvoker)
{
ArgumentNullException.ThrowIfNull(controller);
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
return CreateErroredActionResult(controller, authorityResponse) ?? controller.NoContent();
}
/// <inheritdoc />
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.Invoke<TResult, TApiModel>(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.Invoke<TResult, TApiModel>(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
{
ArgumentNullException.ThrowIfNull(controller);
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
var erroredResult = CreateErroredActionResult(controller, authorityResponse);
if (erroredResult != null)
return erroredResult;
return CreateSuccessfulActionResult(controller, result => result, authorityResponse);
return CreateSuccessfulActionResult(controller, result => result, authorityResponse!);
}
/// <inheritdoc />
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.InvokeTransformable<TResult, TApiModel>(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.InvokeTransformable<TResult, TApiModel>(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
{
ArgumentNullException.ThrowIfNull(controller);
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
var erroredResult = CreateErroredActionResult(controller, authorityResponse);
if (erroredResult != null)
return erroredResult;
return CreateSuccessfulActionResult(controller, result => result.ToApi(), authorityResponse);
return CreateSuccessfulActionResult(controller, result => result.ToApi(), authorityResponse!);
}
}
}
@@ -1,11 +1,8 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Authority
{
@@ -19,9 +16,8 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
/// <param name="forceFresh">Bypass the caching that the authority performs for this request, forcing it to contact GitHub.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="AdministrationResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ChangeVersion)]
ValueTask<AuthorityResponse<AdministrationResponse>> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="AdministrationResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<AdministrationResponse>> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken);
/// <summary>
/// Triggers a restart of tgstation-server without terminating running game instances, setting its version to a given <paramref name="targetVersion"/>.
@@ -29,15 +25,21 @@ namespace Tgstation.Server.Host.Authority
/// <param name="targetVersion">The <see cref="Version"/> TGS will switch to upon reboot.</param>
/// <param name="uploadZip">If <see langword="true"/> a <see cref="FileTicketResponse.FileTicket"/> will be returned and the call must provide an uploaded zip file containing the update data to the file transfer service.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="ServerUpdateResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ChangeVersion | AdministrationRights.UploadVersion)]
ValueTask<AuthorityResponse<ServerUpdateResponse>> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="ServerUpdateResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<ServerUpdateResponse>> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken);
/// <summary>
/// Triggers a restart of tgstation-server without terminating running game instances.
/// </summary>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse"/>.</returns>
[TgsAuthorize(AdministrationRights.RestartHost)]
ValueTask<AuthorityResponse> TriggerServerRestart();
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse"/>.</returns>
RequirementsGated<AuthorityResponse> TriggerServerRestart();
/// <summary>
/// Get a ticket for downloading a log file at a given <paramref name="path"/>.
/// </summary>
/// <param name="path">The relative path to the log file in the directory.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="LogFileResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<LogFileResponse>> GetLog(string path, CancellationToken cancellationToken);
}
}
@@ -0,0 +1,39 @@
using System.Collections.Generic;
using System.Threading;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Models;
namespace Tgstation.Server.Host.Authority
{
/// <summary>
/// <see cref="IAuthority"/> for manipulating chat bots.
/// </summary>
public interface IChatAuthority : IAuthority
{
/// <summary>
/// Create a new <see cref="ChatBot"/>.
/// </summary>
/// <param name="initialChannels">The initial <see cref="ChatChannel"/>s for the chat bot. Must have been previously validated to be compatible with the <paramref name="provider"/>.</param>
/// <param name="name">The name of the chat bot.</param>
/// <param name="connectionString">The connection string for the chat bot.</param>
/// <param name="provider">The <see cref="ChatProvider"/> to use.</param>
/// <param name="instanceId">The ID of the instance the chat bot belongs to.</param>
/// <param name="reconnectionInterval">The interval in minutes that TGS attempts to reconnect the chat provider if it disconnects while it is enabled.</param>
/// <param name="channelLimit">The maximum number of channels that can be created for the chat bot.</param>
/// <param name="enabled">If the chat bot is enabled.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for operations.</param>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/> for the created <see cref="ChatBot"/>.</returns>
RequirementsGated<AuthorityResponse<ChatBot>> Create(
IEnumerable<Models.ChatChannel> initialChannels,
string name,
string connectionString,
ChatProvider provider,
long instanceId,
uint? reconnectionInterval,
ushort? channelLimit,
bool enabled,
CancellationToken cancellationToken);
}
}
@@ -1,7 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using GreenDonut.Data;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.GraphQL.Types;
using Tgstation.Server.Host.Models;
namespace Tgstation.Server.Host.Authority
@@ -10,24 +16,25 @@ namespace Tgstation.Server.Host.Authority
/// Invokes <typeparamref name="TAuthority"/>s from GraphQL endpoints.
/// </summary>
/// <typeparam name="TAuthority">The <see cref="IAuthority"/> invoked.</typeparam>
/// <remarks>We take the approach that fields should be non-nullable if that is the case under ideal circumstances. Authorization issues should throw.</remarks>
public interface IGraphQLAuthorityInvoker<TAuthority> : IAuthorityInvoker<TAuthority>
where TAuthority : IAuthority
{
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method with no success result.
/// </summary>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse"/>.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
ValueTask Invoke(Func<TAuthority, ValueTask<AuthorityResponse>> authorityInvoker);
ValueTask Invoke(Func<TAuthority, RequirementsGated<AuthorityResponse>> authorityInvoker);
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the result.
/// </summary>
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the return value.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<TApiModel?> InvokeAllowMissing<TResult, TApiModel>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<TApiModel?> InvokeAllowMissing<TResult, TApiModel>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : TApiModel
where TApiModel : notnull;
@@ -37,9 +44,9 @@ namespace Tgstation.Server.Host.Authority
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the return value.</typeparam>
/// <typeparam name="TTransformer">The <see cref="ITransformer{TInput, TOutput}"/> for converting <typeparamref name="TResult"/>s to <typeparamref name="TApiModel"/>s.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<TApiModel?> InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<TApiModel?> InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : notnull, IApiTransformable<TResult, TApiModel, TTransformer>
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
@@ -49,9 +56,9 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the return value.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<TApiModel> Invoke<TResult, TApiModel>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<TApiModel> Invoke<TResult, TApiModel>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : TApiModel
where TApiModel : notnull;
@@ -61,11 +68,45 @@ namespace Tgstation.Server.Host.Authority
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the return value.</typeparam>
/// <typeparam name="TTransformer">The <see cref="ITransformer{TInput, TOutput}"/> for converting <typeparamref name="TResult"/>s to <typeparamref name="TApiModel"/>s.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<TApiModel> InvokeTransformable<TResult, TApiModel, TTransformer>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<TApiModel> InvokeTransformable<TResult, TApiModel, TTransformer>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : notnull, IApiTransformable<TResult, TApiModel, TTransformer>
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the transformed result.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> returned by the <typeparamref name="TAuthority"/>.</typeparam>
/// <typeparam name="TApiModel">The returned <see cref="Type"/>.</typeparam>
/// <typeparam name="TTransformer">The <see cref="ITransformer{TInput, TOutput}"/> for converting <typeparamref name="TResult"/>s to <typeparamref name="TApiModel"/>s.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="IQueryable{T}"/> <typeparamref name="TResult"/>.</param>
/// <param name="preTransformer">Optional transformer for the <see cref="IQueryable{T}"/> run once it has been acquired.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IQueryable{T}"/> <typeparamref name="TResult"/> returned on success or <see langword="null"/> if the requirements weren't satisfied.</returns>
ValueTask<IQueryable<TApiModel>> InvokeTransformableQueryable<TResult, TApiModel, TTransformer>(
Func<TAuthority, RequirementsGated<IQueryable<TResult>>> authorityInvoker,
Func<IQueryable<TResult>, IQueryable<TResult>>? preTransformer = null)
where TResult : IApiTransformable<TResult, TApiModel, TTransformer>
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
/// <summary>
/// Execute a batch loaded call on a given <paramref name="authorityInvoker"/>.
/// </summary>
/// <typeparam name="TResult">The queried result of the <typeparamref name="TAuthority"/>.</typeparam>
/// <typeparam name="TApiModel">The returned <see cref="Type"/>.</typeparam>
/// <typeparam name="TTransformer">The <see cref="ITransformer{TInput, TOutput}"/> for converting <typeparamref name="TResult"/>s to <typeparamref name="TApiModel"/>s.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="RequirementsGated{TResult}"/> <see cref="Projectable{TQueried, TResult}"/> from <typeparamref name="TResult"/> to <typeparamref name="TApiModel"/>.</param>
/// <param name="ids">The list of <see cref="EntityId.Id"/>s to batch load.</param>
/// <param name="queryContext">The active <see cref="QueryContext{TEntity}"/> mapped to an <see cref="AuthorityResponse{TResult}"/> of the <typeparamref name="TApiModel"/>. MUST have been wrapped with <see cref="Extensions.QueryContextExtensions.AuthorityResponseWrap{TResult}(QueryContext{TResult})"/>. This will be unwrapped so only a <see cref="QueryContext{TEntity}"/> of <typeparamref name="TApiModel"/> will be used.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="Dictionary{TKey, TValue}"/> of loaded <see cref="EntityId.Id"/>s to <see cref="AuthorityResponse{TResult}"/>s for the loaded <typeparamref name="TApiModel"/>s.</returns>
ValueTask<Dictionary<long, AuthorityResponse<TApiModel>>> ExecuteDataLoader<TResult, TApiModel, TTransformer>(
Func<TAuthority, long, RequirementsGated<Projectable<TResult, TApiModel>>> authorityInvoker,
IReadOnlyList<long> ids,
QueryContext<AuthorityResponse<TApiModel>>? queryContext)
where TResult : EntityId, IApiTransformable<TResult, TApiModel, TTransformer>
where TApiModel : Entity
where TTransformer : ITransformer<TResult, TApiModel>, new();
}
}
@@ -16,13 +16,13 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="LoginResult"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<LoginResult>> AttemptLogin(CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<LoginResult>> AttemptLogin(CancellationToken cancellationToken);
/// <summary>
/// Attempt to login to an OAuth service with the current OAuth credentials.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in an <see cref="OAuthGatewayLoginResult"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<OAuthGatewayLoginResult>> AttemptOAuthGatewayLogin(CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<OAuthGatewayLoginResult>> AttemptOAuthGatewayLogin(CancellationToken cancellationToken);
}
}
@@ -1,10 +1,8 @@
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
{
@@ -20,7 +18,6 @@ namespace Tgstation.Server.Host.Authority
/// <param name="lookupType">The <see cref="PermissionSetLookupType"/> of <paramref name="id"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="PermissionSet"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
ValueTask<AuthorityResponse<PermissionSet>> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<PermissionSet>> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken);
}
}
@@ -20,9 +20,9 @@ namespace Tgstation.Server.Host.Authority
/// Invoke a <typeparamref name="TAuthority"/> method with no success result.
/// </summary>
/// <param name="controller">The <see cref="ApiController"/> invoking the <typeparamref name="TAuthority"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> generated for the resulting <see cref="AuthorityResponse"/>.</returns>
ValueTask<IActionResult> Invoke(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse>> authorityInvoker);
ValueTask<IActionResult> Invoke(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse>> authorityInvoker);
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the result.
@@ -30,9 +30,9 @@ namespace Tgstation.Server.Host.Authority
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the <see cref="IActionResult"/>.</typeparam>
/// <param name="controller">The <see cref="ApiController"/> invoking the <typeparamref name="TAuthority"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<IActionResult> Invoke<TResult, TApiModel>(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<IActionResult> Invoke<TResult, TApiModel>(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : TApiModel
where TApiModel : notnull;
@@ -42,9 +42,9 @@ namespace Tgstation.Server.Host.Authority
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The returned REST <see cref="Type"/>.</typeparam>
/// <param name="controller">The <see cref="ApiController"/> invoking the <typeparamref name="TAuthority"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<IActionResult> InvokeTransformable<TResult, TApiModel>(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<IActionResult> InvokeTransformable<TResult, TApiModel>(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : notnull, ILegacyApiTransformable<TApiModel>
where TApiModel : notnull;
}
@@ -1,13 +1,10 @@
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
{
@@ -20,9 +17,8 @@ namespace Tgstation.Server.Host.Authority
/// Gets the currently authenticated user.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize]
ValueTask<AuthorityResponse<User>> Read(CancellationToken cancellationToken);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<User>> Read(CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="User"/> with a given <paramref name="id"/>.
@@ -31,33 +27,42 @@ namespace Tgstation.Server.Host.Authority
/// <param name="includeJoins">If related entities should be loaded.</param>
/// <param name="allowSystemUser">If the <see cref="User.TgsSystemUserName"/> may be returned.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
ValueTask<AuthorityResponse<User>> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<User>> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="User"/> with a given <paramref name="id"/>.
/// </summary>
/// <typeparam name="TResult">The result type after projection.</typeparam>
/// <param name="id">The <see cref="EntityId.Id"/> of the <see cref="User"/>.</param>
/// <param name="allowSystemUser">If the <see cref="User.TgsSystemUserName"/> may be returned.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="Projectable{TQueried, TResult}"/> <see cref="User"/> for <typeparamref name="TResult"/>.</returns>
RequirementsGated<Projectable<User, TResult>> GetId<TResult>(long id, bool allowSystemUser, CancellationToken cancellationToken)
where TResult : class;
/// <summary>
/// Gets the <see cref="GraphQL.Types.OAuth.OAuthConnection"/>s for the <see cref="User"/> with a given <paramref name="userId"/>.
/// </summary>
/// <param name="userId">The <see cref="EntityId.Id"/> of the <see cref="User"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in an <see cref="global::System.Array"/> of <see cref="GraphQL.Types.OAuth.OAuthConnection"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>> OAuthConnections(long userId, CancellationToken cancellationToken);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="global::System.Array"/> of <see cref="GraphQL.Types.OAuth.OAuthConnection"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>> OAuthConnections(long userId, CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="GraphQL.Types.OAuth.OidcConnection"/>s for the <see cref="User"/> with a given <paramref name="userId"/>.
/// </summary>
/// <param name="userId">The <see cref="EntityId.Id"/> of the <see cref="User"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in an <see cref="global::System.Array"/> of <see cref="GraphQL.Types.OAuth.OidcConnection"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>> OidcConnections(long userId, CancellationToken cancellationToken);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="global::System.Array"/> of <see cref="GraphQL.Types.OAuth.OidcConnection"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>> OidcConnections(long userId, CancellationToken cancellationToken);
/// <summary>
/// Gets all registered <see cref="User"/>s.
/// </summary>
/// <param name="includeJoins">If related entities should be loaded.</param>
/// <returns>A <see cref="IQueryable{T}"/> of <see cref="User"/>s.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
IQueryable<User> Queryable(bool includeJoins);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="IQueryable{T}"/> of <see cref="User"/>s.</returns>
RequirementsGated<IQueryable<User>> Queryable(bool includeJoins);
/// <summary>
/// Creates a <see cref="User"/>.
@@ -65,9 +70,8 @@ namespace Tgstation.Server.Host.Authority
/// <param name="createRequest">The <see cref="UserCreateRequest"/>.</param>
/// <param name="needZeroLengthPasswordWithOAuthConnections">If a zero-length <see cref="UserUpdateRequest.Password"/> indicates and OAuth only user.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in am <see cref="AuthorityResponse{TResult}"/> for the created <see cref="User"/>.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers)]
ValueTask<AuthorityResponse<User>> Create(
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/> for the created <see cref="UpdatedUser"/>.</returns>
RequirementsGated<AuthorityResponse<UpdatedUser>> Create(
UserCreateRequest createRequest,
bool? needZeroLengthPasswordWithOAuthConnections,
CancellationToken cancellationToken);
@@ -77,8 +81,7 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
/// <param name="updateRequest">The <see cref="UserUpdateRequest"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in am <see cref="AuthorityResponse{TResult}"/> for the created <see cref="User"/>.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnServiceConnections)]
ValueTask<AuthorityResponse<User>> Update(UserUpdateRequest updateRequest, CancellationToken cancellationToken);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/> for the created <see cref="UpdatedUser"/>.</returns>
RequirementsGated<AuthorityResponse<UpdatedUser>> Update(UserUpdateRequest updateRequest, CancellationToken cancellationToken);
}
}
@@ -17,8 +17,9 @@ namespace Tgstation.Server.Host.Authority
/// <summary>
/// Gets the current <see cref="UserGroup"/>.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="UserGroup"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<UserGroup>> Read();
RequirementsGated<AuthorityResponse<UserGroup>> Read(CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="UserGroup"/> with a given <paramref name="id"/>.
@@ -26,17 +27,17 @@ namespace Tgstation.Server.Host.Authority
/// <param name="id">The <see cref="Api.Models.EntityId.Id"/> of the <see cref="UserGroup"/>.</param>
/// <param name="includeJoins">If related entities should be loaded.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
ValueTask<AuthorityResponse<UserGroup>> GetId(long id, bool includeJoins, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<UserGroup>> GetId(long id, bool includeJoins, CancellationToken cancellationToken);
/// <summary>
/// Gets all registered <see cref="UserGroup"/>s.
/// </summary>
/// <param name="includeJoins">If related entities should be loaded.</param>
/// <returns>A <see cref="IQueryable{T}"/> of <see cref="UserGroup"/>s.</returns>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="IQueryable{T}"/> of <see cref="UserGroup"/>s.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
IQueryable<UserGroup> Queryable(bool includeJoins);
RequirementsGated<IQueryable<UserGroup>> Queryable(bool includeJoins);
/// <summary>
/// Create a <see cref="UserGroup"/>.
@@ -44,9 +45,9 @@ namespace Tgstation.Server.Host.Authority
/// <param name="name">The created <see cref="UserGroup"/>'s <see cref="Api.Models.NamedEntity.Name"/>.</param>
/// <param name="permissionSet">The created <see cref="UserGroup"/>'s <see cref="UserGroup.PermissionSet"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="UserGroup"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="UserGroup"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers)]
ValueTask<AuthorityResponse<UserGroup>> Create(string name, PermissionSet? permissionSet, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<UserGroup>> Create(string name, PermissionSet? permissionSet, CancellationToken cancellationToken);
/// <summary>
/// Updates a <see cref="UserGroup"/>.
@@ -55,17 +56,17 @@ namespace Tgstation.Server.Host.Authority
/// <param name="newName">The optional new <see cref="Api.Models.NamedEntity.Name"/> for the <see cref="UserGroup"/>.</param>
/// <param name="newPermissionSet">The optional new <see cref="UserGroup.PermissionSet"/> for the <see cref="UserGroup"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="UserGroup"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="RequirementsGated{TResult}"/> <see cref="UserGroup"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers)]
ValueTask<AuthorityResponse<UserGroup>> Update(long id, string? newName, PermissionSet? newPermissionSet, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<UserGroup>> Update(long id, string? newName, PermissionSet? newPermissionSet, CancellationToken cancellationToken);
/// <summary>
/// Deletes an empty <see cref="UserGroup"/>.
/// </summary>
/// <param name="id">The <see cref="Api.Models.EntityId.Id"/> of the <see cref="UserGroup"/> to delete.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse"/> representing the running operation.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers)]
ValueTask<AuthorityResponse> DeleteEmpty(long id, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse> DeleteEmpty(long id, CancellationToken cancellationToken);
}
}
@@ -3,6 +3,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -61,9 +62,9 @@ namespace Tgstation.Server.Host.Authority
readonly ISessionInvalidationTracker sessionInvalidationTracker;
/// <summary>
/// The <see cref="SecurityConfiguration"/> for the <see cref="LoginAuthority"/>.
/// The <see cref="IOptionsSnapshot{TOptions}"/> containing the <see cref="SecurityConfiguration"/> for the <see cref="LoginAuthority"/>.
/// </summary>
readonly SecurityConfiguration securityConfiguration;
readonly IOptionsSnapshot<SecurityConfiguration> securityConfigurationOptions;
/// <summary>
/// Generate an <see cref="AuthorityResponse{TResult}"/> for a given <paramref name="headersException"/>.
@@ -103,7 +104,6 @@ namespace Tgstation.Server.Host.Authority
/// <summary>
/// Initializes a new instance of the <see cref="LoginAuthority"/> class.
/// </summary>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to use.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="apiHeadersProvider">The value of <see cref="apiHeadersProvider"/>.</param>
@@ -113,9 +113,8 @@ namespace Tgstation.Server.Host.Authority
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/>.</param>
/// <param name="identityCache">The value of <see cref="identityCache"/>.</param>
/// <param name="sessionInvalidationTracker">The value of <see cref="sessionInvalidationTracker"/>.</param>
/// <param name="securityConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="securityConfiguration"/>.</param>
/// <param name="securityConfigurationOptions">The value of <see cref="securityConfigurationOptions"/>.</param>
public LoginAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<LoginAuthority> logger,
IApiHeadersProvider apiHeadersProvider,
@@ -125,9 +124,8 @@ namespace Tgstation.Server.Host.Authority
ICryptographySuite cryptographySuite,
IIdentityCache identityCache,
ISessionInvalidationTracker sessionInvalidationTracker,
IOptions<SecurityConfiguration> securityConfigurationOptions)
IOptionsSnapshot<SecurityConfiguration> securityConfigurationOptions)
: base(
authenticationContext,
databaseContext,
logger)
{
@@ -138,14 +136,52 @@ namespace Tgstation.Server.Host.Authority
this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite));
this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache));
this.sessionInvalidationTracker = sessionInvalidationTracker ?? throw new ArgumentNullException(nameof(sessionInvalidationTracker));
securityConfiguration = securityConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(securityConfigurationOptions));
this.securityConfigurationOptions = securityConfigurationOptions ?? throw new ArgumentNullException(nameof(securityConfigurationOptions));
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<LoginResult>> AttemptLogin(CancellationToken cancellationToken)
public RequirementsGated<AuthorityResponse<LoginResult>> AttemptLogin(CancellationToken cancellationToken)
=> new(
() => null,
() => AttemptLoginImpl(cancellationToken),
doNotAddUserSessionValidRequirement: true);
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<OAuthGatewayLoginResult>> AttemptOAuthGatewayLogin(CancellationToken cancellationToken)
=> new(
() => (IAuthorizationRequirement?)null,
async () =>
{
var headers = apiHeadersProvider.ApiHeaders;
if (headers == null)
return GenerateHeadersExceptionResponse<OAuthGatewayLoginResult>(apiHeadersProvider.HeadersException!);
var oAuthProvider = headers.OAuthProvider;
if (!oAuthProvider.HasValue)
return BadRequest<OAuthGatewayLoginResult>(ErrorCode.BadHeaders);
var (errorResponse, oAuthResult) = await TryOAuthenticate<OAuthGatewayLoginResult>(headers, oAuthProvider.Value, false, cancellationToken);
if (errorResponse != null)
return errorResponse;
Logger.LogDebug("Generated {provider} OAuth AccessCode", oAuthProvider.Value);
return new(
new OAuthGatewayLoginResult
{
AccessCode = oAuthResult!.Value.AccessCode,
});
});
/// <summary>
/// Login process.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/> for the <see cref="LoginResult"/>.</returns>
private async ValueTask<AuthorityResponse<LoginResult>> AttemptLoginImpl(CancellationToken cancellationToken)
{
// password and oauth logins disabled
if (securityConfiguration.OidcStrictMode)
if (securityConfigurationOptions.Value.OidcStrictMode)
return Unauthorized<LoginResult>();
var headers = apiHeadersProvider.ApiHeaders;
@@ -172,7 +208,7 @@ namespace Tgstation.Server.Host.Authority
using (systemIdentity)
{
// Get the user from the database
IQueryable<User> query = DatabaseContext.Users.AsQueryable();
IQueryable<User> query = DatabaseContext.Users;
if (oAuthLogin)
{
var oAuthProvider = headers.OAuthProvider!.Value;
@@ -278,30 +314,6 @@ namespace Tgstation.Server.Host.Authority
}
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<OAuthGatewayLoginResult>> AttemptOAuthGatewayLogin(CancellationToken cancellationToken)
{
var headers = apiHeadersProvider.ApiHeaders;
if (headers == null)
return GenerateHeadersExceptionResponse<OAuthGatewayLoginResult>(apiHeadersProvider.HeadersException!);
var oAuthProvider = headers.OAuthProvider;
if (!oAuthProvider.HasValue)
return BadRequest<OAuthGatewayLoginResult>(ErrorCode.BadHeaders);
var (errorResponse, oAuthResult) = await TryOAuthenticate<OAuthGatewayLoginResult>(headers, oAuthProvider.Value, false, cancellationToken);
if (errorResponse != null)
return errorResponse;
Logger.LogDebug("Generated {provider} OAuth AccessCode", oAuthProvider.Value);
return new AuthorityResponse<OAuthGatewayLoginResult>(
new OAuthGatewayLoginResult
{
AccessCode = oAuthResult!.Value.AccessCode,
});
}
/// <summary>
/// Add a given <paramref name="systemIdentity"/> to the <see cref="identityCache"/>.
/// </summary>
@@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
@@ -25,6 +26,11 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
readonly IPermissionSetsDataLoader permissionSetsDataLoader;
/// <summary>
/// The <see cref="IClaimsPrincipalAccessor"/> for the <see cref="PermissionSetAuthority"/>.
/// </summary>
readonly IClaimsPrincipalAccessor claimsPrincipalAccessor;
/// <summary>
/// Implements <see cref="permissionSetsDataLoader"/>.
/// </summary>
@@ -84,34 +90,58 @@ namespace Tgstation.Server.Host.Authority
/// <summary>
/// Initializes a new instance of the <see cref="PermissionSetAuthority"/> class.
/// </summary>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to use.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="permissionSetsDataLoader">The value of <see cref="permissionSetsDataLoader"/>.</param>
/// <param name="claimsPrincipalAccessor">The value of <see cref="claimsPrincipalAccessor"/>.</param>
public PermissionSetAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<AuthorityBase> logger,
IPermissionSetsDataLoader permissionSetsDataLoader)
IPermissionSetsDataLoader permissionSetsDataLoader,
IClaimsPrincipalAccessor claimsPrincipalAccessor)
: base(
authenticationContext,
databaseContext,
logger)
{
this.permissionSetsDataLoader = permissionSetsDataLoader ?? throw new ArgumentNullException(nameof(permissionSetsDataLoader));
this.claimsPrincipalAccessor = claimsPrincipalAccessor ?? throw new ArgumentNullException(nameof(claimsPrincipalAccessor));
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<PermissionSet>> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken)
public RequirementsGated<AuthorityResponse<PermissionSet>> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken)
{
if (id != AuthenticationContext.PermissionSet.Id && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers))
return Forbid<PermissionSet>();
var permissionSetTask = permissionSetsDataLoader.LoadAsync((Id: id, LookupType: lookupType), cancellationToken);
return new(
async () =>
{
var userId = claimsPrincipalAccessor.User.GetTgsUserId();
var permissionSet = await permissionSetsDataLoader.LoadAsync((Id: id, LookupType: lookupType), cancellationToken);
if (permissionSet == null)
return NotFound<PermissionSet>();
var groupIdQuery = DatabaseContext
.Users
.Where(user => user.Id == userId)
.Select(user => user.GroupId);
return new AuthorityResponse<PermissionSet>(permissionSet);
var permissionSetId = await DatabaseContext
.PermissionSets
.Where(permissionSet => permissionSet.UserId == userId
|| groupIdQuery.Contains(permissionSet.GroupId))
.Select(permissionSet => permissionSet.Id!.Value)
.FirstAsync(cancellationToken);
if (permissionSetId == id)
return null;
return Flag(AdministrationRights.ReadUsers);
},
async () =>
{
var permissionSet = await permissionSetTask;
if (permissionSet == null)
return NotFound<PermissionSet>();
return new AuthorityResponse<PermissionSet>(permissionSet);
});
}
}
}
@@ -9,6 +9,7 @@ using GreenDonut;
using HotChocolate.Subscriptions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -22,9 +23,11 @@ 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.Extensions;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Models.Transformers;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Security.RightsEvaluation;
namespace Tgstation.Server.Host.Authority
{
@@ -71,15 +74,20 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
readonly ITopicEventSender topicEventSender;
/// <summary>
/// The <see cref="IClaimsPrincipalAccessor"/> for the <see cref="UserAuthority"/>.
/// </summary>
readonly IClaimsPrincipalAccessor claimsPrincipalAccessor;
/// <summary>
/// The <see cref="IOptionsSnapshot{TOptions}"/> of <see cref="GeneralConfiguration"/> for the <see cref="UserAuthority"/>.
/// </summary>
readonly IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions;
/// <summary>
/// The <see cref="IOptions{TOptions}"/> of <see cref="SecurityConfiguration"/> for the <see cref="UserAuthority"/>.
/// The <see cref="IOptionsSnapshot{TOptions}"/> of <see cref="SecurityConfiguration"/> for the <see cref="UserAuthority"/>.
/// </summary>
readonly IOptions<SecurityConfiguration> securityConfigurationOptions;
readonly IOptionsSnapshot<SecurityConfiguration> securityConfigurationOptions;
/// <summary>
/// Implements the <see cref="usersDataLoader"/>.
@@ -99,7 +107,6 @@ namespace Tgstation.Server.Host.Authority
return databaseContext
.Users
.AsQueryable()
.Where(x => ids.Contains(x.Id!.Value))
.ToDictionaryAsync(user => user.Id!.Value, cancellationToken);
}
@@ -122,13 +129,16 @@ namespace Tgstation.Server.Host.Authority
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));
x => new GraphQL.Types.OAuth.OAuthConnection
{
ExternalUserId = x.ExternalUserId!,
Provider = x.Provider,
});
}
/// <summary>
@@ -149,13 +159,16 @@ namespace Tgstation.Server.Host.Authority
var list = await databaseContext
.OidcConnections
.AsQueryable()
.Where(x => userIds.Contains(x.User!.Id!.Value))
.ToListAsync(cancellationToken);
return list.ToLookup(
oidcConnection => oidcConnection.UserId,
x => new GraphQL.Types.OAuth.OidcConnection(x.ExternalUserId!, x.SchemeKey!));
x => new GraphQL.Types.OAuth.OidcConnection
{
ExternalUserId = x.ExternalUserId!,
SchemeKey = x.SchemeKey!,
});
}
/// <summary>
@@ -164,22 +177,21 @@ namespace Tgstation.Server.Host.Authority
/// <param name="model">The <see cref="UserUpdateRequest"/> to check.</param>
/// <param name="newUser">If this is a new <see cref="User"/>.</param>
/// <returns><see langword="null"/> if <paramref name="model"/> is valid, an <see cref="AuthorityResponse{TResult}"/> errored otherwise.</returns>
static AuthorityResponse<User>? CheckValidName(UserUpdateRequest model, bool newUser)
static AuthorityResponse<UpdatedUser>? 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<User>(ErrorCode.UserMissingName);
return BadRequest<UpdatedUser>(ErrorCode.UserMissingName);
model.Name = model.Name?.Trim();
if (model.Name != null && model.Name.Contains(':', StringComparison.InvariantCulture))
return BadRequest<User>(ErrorCode.UserColonInName);
return BadRequest<UpdatedUser>(ErrorCode.UserColonInName);
return null;
}
/// <summary>
/// Initializes a new instance of the <see cref="UserAuthority"/> class.
/// </summary>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to use.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="usersDataLoader">The value of <see cref="usersDataLoader"/>.</param>
@@ -190,10 +202,10 @@ namespace Tgstation.Server.Host.Authority
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/>.</param>
/// <param name="sessionInvalidationTracker">The value of <see cref="sessionInvalidationTracker"/>.</param>
/// <param name="topicEventSender">The value of <see cref="topicEventSender"/>.</param>
/// <param name="claimsPrincipalAccessor">The value of <see cref="claimsPrincipalAccessor"/>.</param>
/// <param name="generalConfigurationOptions">The value of <see cref="generalConfigurationOptions"/>.</param>
/// <param name="securityConfigurationOptions">The value of <see cref="securityConfigurationOptions"/>.</param>
public UserAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<UserAuthority> logger,
IUsersDataLoader usersDataLoader,
@@ -204,10 +216,10 @@ namespace Tgstation.Server.Host.Authority
ICryptographySuite cryptographySuite,
ISessionInvalidationTracker sessionInvalidationTracker,
ITopicEventSender topicEventSender,
IClaimsPrincipalAccessor claimsPrincipalAccessor,
IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions,
IOptions<SecurityConfiguration> securityConfigurationOptions)
IOptionsSnapshot<SecurityConfiguration> securityConfigurationOptions)
: base(
authenticationContext,
databaseContext,
logger)
{
@@ -219,6 +231,7 @@ namespace Tgstation.Server.Host.Authority
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.claimsPrincipalAccessor = claimsPrincipalAccessor ?? throw new ArgumentNullException(nameof(claimsPrincipalAccessor));
this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
this.securityConfigurationOptions = securityConfigurationOptions ?? throw new ArgumentNullException(nameof(securityConfigurationOptions));
}
@@ -233,11 +246,11 @@ namespace Tgstation.Server.Host.Authority
static bool BadCreateRequestChecks(
UserCreateRequest createRequest,
bool? needZeroLengthPasswordWithOAuthConnections,
[NotNullWhen(true)] out AuthorityResponse<User>? failResponse)
[NotNullWhen(true)] out AuthorityResponse<UpdatedUser>? failResponse)
{
if (createRequest.OAuthConnections?.Any(x => x == null) == true)
{
failResponse = BadRequest<User>(ErrorCode.ModelValidationFailure);
failResponse = BadRequest<UpdatedUser>(ErrorCode.ModelValidationFailure);
return true;
}
@@ -247,7 +260,7 @@ namespace Tgstation.Server.Host.Authority
if ((hasNonNullPassword && hasNonNullSystemIdentifier)
|| (!hasNonNullPassword && !hasNonNullSystemIdentifier && !hasOAuthConnections))
{
failResponse = BadRequest<User>(ErrorCode.UserMismatchPasswordSid);
failResponse = BadRequest<UpdatedUser>(ErrorCode.UserMismatchPasswordSid);
return true;
}
@@ -261,20 +274,20 @@ namespace Tgstation.Server.Host.Authority
if (createRequest.OAuthConnections.Count == 0)
{
failResponse = BadRequest<User>(ErrorCode.ModelValidationFailure);
failResponse = BadRequest<UpdatedUser>(ErrorCode.ModelValidationFailure);
return true;
}
}
else if (hasZeroLengthPassword)
{
failResponse = BadRequest<User>(ErrorCode.ModelValidationFailure);
failResponse = BadRequest<UpdatedUser>(ErrorCode.ModelValidationFailure);
return true;
}
}
if (createRequest.Group != null && createRequest.PermissionSet != null)
{
failResponse = BadRequest<User>(ErrorCode.UserGroupAndPermissionSet);
failResponse = BadRequest<UpdatedUser>(ErrorCode.UserGroupAndPermissionSet);
return true;
}
@@ -284,7 +297,7 @@ namespace Tgstation.Server.Host.Authority
if (!(createRequest.Name == null ^ createRequest.SystemIdentifier == null))
{
failResponse = BadRequest<User>(ErrorCode.UserMismatchNameSid);
failResponse = BadRequest<UpdatedUser>(ErrorCode.UserMismatchNameSid);
return true;
}
@@ -293,15 +306,384 @@ namespace Tgstation.Server.Host.Authority
}
/// <inheritdoc />
public ValueTask<AuthorityResponse<User>> Read(CancellationToken cancellationToken)
=> ValueTask.FromResult(new AuthorityResponse<User>(AuthenticationContext.User));
public RequirementsGated<AuthorityResponse<User>> Read(CancellationToken cancellationToken)
=> new(
() => Enumerable.Empty<IAuthorizationRequirement>(),
() => GetIdImpl(claimsPrincipalAccessor.User.RequireTgsUserId(), true, false, cancellationToken));
/// <inheritdoc />
public async ValueTask<AuthorityResponse<User>> 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>();
public RequirementsGated<AuthorityResponse<User>> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken)
=> new(
() =>
{
if (id != claimsPrincipalAccessor.User.GetTgsUserId())
return Enumerable.Empty<IAuthorizationRequirement>();
return new List<IAuthorizationRequirement>
{
Flag(AdministrationRights.ReadUsers),
};
},
() => GetIdImpl(id, includeJoins, allowSystemUser, cancellationToken));
/// <inheritdoc />
public RequirementsGated<IQueryable<User>> Queryable(bool includeJoins)
=> new(
() => Flag(AdministrationRights.ReadUsers),
() => ValueTask.FromResult(Queryable(includeJoins, false)));
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>> OAuthConnections(long userId, CancellationToken cancellationToken)
=> new(
() => claimsPrincipalAccessor.User.GetTgsUserId() != userId
? Flag(AdministrationRights.ReadUsers)
: null,
async () => new AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>(
await oAuthConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken)));
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>> OidcConnections(long userId, CancellationToken cancellationToken)
=> new(
() => claimsPrincipalAccessor.User.GetTgsUserId() != userId
? Flag(AdministrationRights.ReadUsers)
: null,
async () => new AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>(
await oidcConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken)));
/// <inheritdoc />
#pragma warning disable CA1506 // TODO: Decomplexify
public RequirementsGated<AuthorityResponse<UpdatedUser>> Create(
UserCreateRequest createRequest,
bool? needZeroLengthPasswordWithOAuthConnections,
CancellationToken cancellationToken)
#pragma warning restore CA1506
=> new(
() => Flag(AdministrationRights.WriteUsers),
async authorizationService =>
{
ArgumentNullException.ThrowIfNull(createRequest);
if (BadCreateRequestChecks(createRequest, needZeroLengthPasswordWithOAuthConnections, out var failResponse))
return failResponse;
var totalUsers = await DatabaseContext
.Users
.CountAsync(cancellationToken);
if (totalUsers >= generalConfigurationOptions.Value.UserLimit)
return Conflict<UpdatedUser>(ErrorCode.UserLimitReached);
var dbUser = await CreateNewUserFromModel(
createRequest,
cancellationToken);
if (dbUser == null)
return Gone<UpdatedUser>();
if (createRequest.SystemIdentifier != null)
try
{
using var sysIdentity = await systemIdentityFactory.CreateSystemIdentity(dbUser, cancellationToken);
if (sysIdentity == null)
return Gone<UpdatedUser>();
dbUser.Name = sysIdentity.Username;
dbUser.SystemIdentifier = sysIdentity.Uid;
}
catch (NotImplementedException ex)
{
Logger.LogTrace(ex, "System identities not implemented!");
return new AuthorityResponse<UpdatedUser>(
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);
var responseTask = UpdatedUserResponse(authorizationService, dbUser, HttpSuccessResponse.Created);
await SendUserUpdatedTopics(dbUser);
return await responseTask;
});
/// <inheritdoc />
#pragma warning disable CA1502
#pragma warning disable CA1506 // TODO: Decomplexify
public RequirementsGated<AuthorityResponse<UpdatedUser>> Update(UserUpdateRequest model, CancellationToken cancellationToken)
#pragma warning restore CA1502
#pragma warning restore CA1506
=> new(
() =>
{
RightsConditional<AdministrationRights>? conditional = null;
// Ensure they are only trying to edit things they have perms for (system identity change will trigger a bad request)
if (model.OidcConnections != null || model.OAuthConnections != null)
conditional = Flag(AdministrationRights.EditOwnServiceConnections);
if (model.Password != null && model.Id == claimsPrincipalAccessor.User.GetTgsUserId())
{
var newFlag = Flag(AdministrationRights.EditOwnPassword);
if (conditional != null)
conditional = And(conditional, newFlag);
else
conditional = newFlag;
}
if (conditional != null)
conditional = Or(conditional, Flag(AdministrationRights.WriteUsers));
else if (model.Enabled.HasValue
|| model.Group != null
|| model.Name != null
|| model.PermissionSet != null)
conditional = Flag(AdministrationRights.WriteUsers);
return conditional;
},
async authorizationService =>
{
ArgumentNullException.ThrowIfNull(model);
if (!model.Id.HasValue || model.OAuthConnections?.Any(x => x == null) == true)
return BadRequest<UpdatedUser>(ErrorCode.ModelValidationFailure);
if (model.Group != null && model.PermissionSet != null)
return BadRequest<UpdatedUser>(ErrorCode.UserGroupAndPermissionSet);
var userQuery = DatabaseContext
.Users
.Where(x => x.Id == model.Id)
.Include(x => x.CreatedBy)
.Include(x => x.OAuthConnections)
.Include(x => x.OidcConnections)
.Include(x => x.Group!)
.ThenInclude(x => x.PermissionSet)
.Include(x => x.PermissionSet)
.FirstOrDefaultAsync(cancellationToken);
var originalUser = await userQuery;
if (originalUser == default)
return NotFound<UpdatedUser>();
if (originalUser.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName))
return Forbid<UpdatedUser>();
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<UpdatedUser>(ErrorCode.UserSidChange);
if (model.Password != null)
{
if (originalUserHasSid)
return BadRequest<UpdatedUser>(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<UpdatedUser>(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 (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<UpdatedUser>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName))
return BadRequest<UpdatedUser>(ErrorCode.AdminUserCannotHaveServiceConnection);
if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
return BadRequest<UpdatedUser>(ErrorCode.CannotRemoveLastAuthenticationOption);
DatabaseContext.OAuthConnections.RemoveRange(originalUser.OAuthConnections);
originalUser.OAuthConnections.Clear();
foreach (var updatedConnection in model.OAuthConnections)
originalUser.OAuthConnections.Add(new Models.OAuthConnection
{
Provider = updatedConnection.Provider,
ExternalUserId = updatedConnection.ExternalUserId,
});
}
if (model.OidcConnections != null
&& (model.OidcConnections.Count != originalUser.OidcConnections!.Count
|| !model.OidcConnections.All(x => originalUser.OidcConnections.Any(y => y.SchemeKey == x.SchemeKey && y.ExternalUserId == x.ExternalUserId))))
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<UpdatedUser>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName))
return BadRequest<UpdatedUser>(ErrorCode.AdminUserCannotHaveServiceConnection);
if (model.OidcConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
return BadRequest<UpdatedUser>(ErrorCode.CannotRemoveLastAuthenticationOption);
DatabaseContext.OidcConnections.RemoveRange(originalUser.OidcConnections);
originalUser.OidcConnections.Clear();
foreach (var updatedConnection in model.OidcConnections)
originalUser.OidcConnections.Add(new Models.OidcConnection
{
SchemeKey = updatedConnection.SchemeKey,
ExternalUserId = updatedConnection.ExternalUserId,
});
}
if (model.Group != null)
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<UpdatedUser>(ErrorCode.BadUserEditDueToOidcStrictMode);
originalUser.Group = await DatabaseContext
.Groups
.Where(x => x.Id == model.Group.Id)
.Include(x => x.PermissionSet)
.FirstOrDefaultAsync(cancellationToken);
if (originalUser.Group == default)
return Gone<UpdatedUser>();
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 (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<UpdatedUser>(ErrorCode.BadUserEditDueToOidcStrictMode);
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)
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<UpdatedUser>(ErrorCode.BadUserEditDueToOidcStrictMode);
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);
var responseTask = UpdatedUserResponse(authorizationService, originalUser, HttpSuccessResponse.Ok);
ValueTask sessionInvalidationTask;
if (invalidateSessions)
sessionInvalidationTask = permissionsUpdateNotifyee.UserDisabled(originalUser, cancellationToken);
else
sessionInvalidationTask = ValueTask.CompletedTask;
await ValueTaskExtensions.WhenAll(SendUserUpdatedTopics(originalUser), sessionInvalidationTask);
return await responseTask;
});
/// <inheritdoc />
public RequirementsGated<Projectable<User, TResult>> GetId<TResult>(long id, bool allowSystemUser, CancellationToken cancellationToken)
where TResult : class
=> new(
() =>
{
if (id != claimsPrincipalAccessor.User.GetTgsUserId())
return Enumerable.Empty<IAuthorizationRequirement>();
return new List<IAuthorizationRequirement>
{
Flag(AdministrationRights.ReadUsers),
};
},
() => ValueTask.FromResult(
Projectable<User, TResult>.Create(
Queryable(true, allowSystemUser)
.Where(user => user.Id == id)
.TagWith("User by ID"),
projected => new ProjectedPair<string, TResult>
{
Queried = projected.Queried.CanonicalName!,
Result = projected.Result,
},
projected =>
{
if (projected == default)
return NotFound<TResult>();
string canonicalName = projected.Queried;
if (!allowSystemUser && canonicalName == User.CanonicalizeName(User.TgsSystemUserName))
return Forbid<TResult>();
return new AuthorityResponse<TResult>(projected.Result);
},
cancellationToken)));
/// <summary>
/// Implementation of retrieving a <see cref="User"/> by ID.
/// </summary>
/// <param name="id">The <see cref="EntityId.Id"/> of the user to retrieve.</param>
/// <param name="includeJoins">If related entities should be loaded.</param>
/// <param name="allowSystemUser">If the <see cref="User.TgsSystemUserName"/> may be returned.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
async ValueTask<AuthorityResponse<User>> GetIdImpl(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken)
{
User? user;
if (includeJoins)
{
@@ -323,286 +705,29 @@ namespace Tgstation.Server.Host.Authority
return new AuthorityResponse<User>(user);
}
/// <inheritdoc />
public IQueryable<User> Queryable(bool includeJoins)
=> Queryable(includeJoins, false);
/// <inheritdoc />
public async ValueTask<AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>> OAuthConnections(long userId, CancellationToken cancellationToken)
=> new AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>(
await oAuthConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken));
/// <inheritdoc />
public async ValueTask<AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>> OidcConnections(long userId, CancellationToken cancellationToken)
=> new AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>(
await oidcConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken));
/// <inheritdoc />
public async ValueTask<AuthorityResponse<User>> Create(
UserCreateRequest createRequest,
bool? needZeroLengthPasswordWithOAuthConnections,
CancellationToken cancellationToken)
/// <summary>
/// Create the <see cref="AuthorityResponse{TResult}"/> for an <see cref="UpdatedUser"/>.
/// </summary>
/// <param name="authorizationService">The authorization service to use.</param>
/// <param name="user">The <see cref="User"/> for the result.</param>
/// <param name="successResponse">The <see cref="HttpSuccessResponse"/> to use.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="UpdatedUser"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
async ValueTask<AuthorityResponse<UpdatedUser>> UpdatedUserResponse(
Security.IAuthorizationService authorizationService,
User user,
HttpSuccessResponse successResponse)
{
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<User>(ErrorCode.UserLimitReached);
var dbUser = await CreateNewUserFromModel(createRequest, cancellationToken);
if (dbUser == null)
return Gone<User>();
if (createRequest.SystemIdentifier != null)
try
{
using var sysIdentity = await systemIdentityFactory.CreateSystemIdentity(dbUser, cancellationToken);
if (sysIdentity == null)
return Gone<User>();
dbUser.Name = sysIdentity.Username;
dbUser.SystemIdentifier = sysIdentity.Uid;
}
catch (NotImplementedException ex)
{
Logger.LogTrace(ex, "System identities not implemented!");
return new AuthorityResponse<User>(
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<User>(dbUser, HttpSuccessResponse.Created);
}
/// <inheritdoc />
#pragma warning disable CA1502
#pragma warning disable CA1506 // TODO: Decomplexify
public async ValueTask<AuthorityResponse<User>> 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<User>(ErrorCode.ModelValidationFailure);
if (model.Group != null && model.PermissionSet != null)
return BadRequest<User>(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.EditOwnServiceConnections);
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.OidcConnections)
.Include(x => x.Group!)
.ThenInclude(x => x.PermissionSet)
.Include(x => x.PermissionSet)
.FirstOrDefaultAsync(cancellationToken);
if (originalUser == default)
return NotFound<User>();
if (originalUser.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName))
return Forbid<User>();
// 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<User>();
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<User>(ErrorCode.UserSidChange);
if (model.Password != null)
{
if (originalUserHasSid)
return BadRequest<User>(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<User>(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 (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName))
return BadRequest<User>(ErrorCode.AdminUserCannotHaveServiceConnection);
if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
return BadRequest<User>(ErrorCode.CannotRemoveLastAuthenticationOption);
DatabaseContext.OAuthConnections.RemoveRange(originalUser.OAuthConnections);
originalUser.OAuthConnections.Clear();
foreach (var updatedConnection in model.OAuthConnections)
originalUser.OAuthConnections.Add(new Models.OAuthConnection
{
Provider = updatedConnection.Provider,
ExternalUserId = updatedConnection.ExternalUserId,
});
}
if (model.OidcConnections != null
&& (model.OidcConnections.Count != originalUser.OidcConnections!.Count
|| !model.OidcConnections.All(x => originalUser.OidcConnections.Any(y => y.SchemeKey == x.SchemeKey && y.ExternalUserId == x.ExternalUserId))))
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName))
return BadRequest<User>(ErrorCode.AdminUserCannotHaveServiceConnection);
if (model.OidcConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
return BadRequest<User>(ErrorCode.CannotRemoveLastAuthenticationOption);
DatabaseContext.OidcConnections.RemoveRange(originalUser.OidcConnections);
originalUser.OidcConnections.Clear();
foreach (var updatedConnection in model.OidcConnections)
originalUser.OidcConnections.Add(new Models.OidcConnection
{
SchemeKey = updatedConnection.SchemeKey,
ExternalUserId = updatedConnection.ExternalUserId,
});
}
if (model.Group != null)
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
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<User>();
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 (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
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)
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
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<User>(originalUser)
: new AuthorityResponse<User>();
var userId = user.Require(u => u.Id);
var canReadBack = claimsPrincipalAccessor.User.GetTgsUserId() == userId
|| (await authorizationService.AuthorizeAsync(
[Flag(AdministrationRights.ReadUsers)])).Succeeded;
return new AuthorityResponse<UpdatedUser>(
canReadBack
? new UpdatedUser(user)
: new UpdatedUser(userId),
successResponse);
}
/// <summary>
@@ -628,9 +753,8 @@ namespace Tgstation.Server.Host.Authority
IQueryable<User> Queryable(bool includeJoins, bool allowSystemUser)
{
var tgsUserCanonicalName = User.CanonicalizeName(User.TgsSystemUserName);
var queryable = DatabaseContext
.Users
.AsQueryable();
IQueryable<User> queryable = DatabaseContext
.Users;
if (!allowSystemUser)
queryable = queryable
@@ -661,7 +785,6 @@ namespace Tgstation.Server.Host.Authority
if (model.Group != null)
group = await DatabaseContext
.Groups
.AsQueryable()
.Where(x => x.Id == model.Group.Id)
.Include(x => x.PermissionSet)
.FirstOrDefaultAsync(cancellationToken);
@@ -672,10 +795,23 @@ namespace Tgstation.Server.Host.Authority
InstanceManagerRights = model.PermissionSet?.InstanceManagerRights ?? InstanceManagerRights.None,
};
/*
var currentUser = new User
{
Id = claimsPrincipalAccessor.User.GetTgsUserId(),
};
*/
// Temporary workaround while we work to remove authentication context
var currentUser = DatabaseContext.Users.Local.First(
user => user.Id == claimsPrincipalAccessor.User.GetTgsUserId());
DatabaseContext.Users.Attach(currentUser);
return new User
{
CreatedAt = DateTimeOffset.UtcNow,
CreatedBy = AuthenticationContext.User,
CreatedBy = currentUser,
Enabled = model.Enabled ?? false,
PermissionSet = permissionSet,
Group = group,
@@ -709,11 +845,11 @@ namespace Tgstation.Server.Host.Authority
/// <param name="newPassword">The new password.</param>
/// <param name="newUser">If this is for a new <see cref="UserResponse"/>.</param>
/// <returns><see langword="null"/> on success, an errored <see cref="AuthorityResponse{TResult}"/> if <paramref name="newPassword"/> is too short.</returns>
AuthorityResponse<User>? TrySetPassword(User dbUser, string newPassword, bool newUser)
AuthorityResponse<UpdatedUser>? TrySetPassword(User dbUser, string newPassword, bool newUser)
{
newPassword ??= String.Empty;
if (newPassword.Length < generalConfigurationOptions.Value.MinimumPasswordLength)
return new AuthorityResponse<User>(
return new AuthorityResponse<UpdatedUser>(
new ErrorMessageResponse(ErrorCode.UserPasswordLength)
{
AdditionalData = $"Required password length: {generalConfigurationOptions.Value.MinimumPasswordLength}",
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using GreenDonut;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -16,6 +17,7 @@ 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.Extensions;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
@@ -29,6 +31,11 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
readonly IUserGroupsDataLoader userGroupsDataLoader;
/// <summary>
/// The <see cref="IClaimsPrincipalAccessor"/> for the <see cref="UserGroupAuthority"/>.
/// </summary>
readonly IClaimsPrincipalAccessor claimsPrincipalAccessor;
/// <summary>
/// The <see cref="IOptionsSnapshot{TOptions}"/> of the <see cref="GeneralConfiguration"/>.
/// </summary>
@@ -59,62 +66,179 @@ namespace Tgstation.Server.Host.Authority
/// <summary>
/// Initializes a new instance of the <see cref="UserGroupAuthority"/> class.
/// </summary>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to use.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="claimsPrincipalAccessor">The value of <see cref="claimsPrincipalAccessor"/>.</param>
/// <param name="userGroupsDataLoader">The value of <see cref="userGroupsDataLoader"/>.</param>
/// <param name="generalConfigurationOptions">The value of <see cref="generalConfigurationOptions"/>.</param>
public UserGroupAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<UserGroupAuthority> logger,
IUserGroupsDataLoader userGroupsDataLoader,
IClaimsPrincipalAccessor claimsPrincipalAccessor,
IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions)
: base(
authenticationContext,
databaseContext,
logger)
{
this.userGroupsDataLoader = userGroupsDataLoader ?? throw new ArgumentNullException(nameof(userGroupsDataLoader));
this.claimsPrincipalAccessor = claimsPrincipalAccessor ?? throw new ArgumentNullException(nameof(claimsPrincipalAccessor));
this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<UserGroup>> GetId(long id, bool includeJoins, CancellationToken cancellationToken)
public RequirementsGated<AuthorityResponse<UserGroup>> GetId(long id, bool includeJoins, CancellationToken cancellationToken)
=> new(
() =>
{
if (id != claimsPrincipalAccessor.User.GetTgsUserId())
return Flag(AdministrationRights.ReadUsers);
return null;
},
async () =>
{
UserGroup? userGroup;
if (includeJoins)
userGroup = await QueryableImpl(true)
.Where(x => x.Id == id)
.FirstOrDefaultAsync(cancellationToken);
else
userGroup = await userGroupsDataLoader.LoadAsync(id, cancellationToken);
if (userGroup == null)
return Gone<UserGroup>();
return new AuthorityResponse<UserGroup>(userGroup);
});
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<UserGroup>> Read(CancellationToken cancellationToken)
=> new(
() => (IAuthorizationRequirement?)null,
async () =>
{
var userId = claimsPrincipalAccessor.User.GetTgsUserId();
var group = await DatabaseContext
.Users
.Where(user => user.Id == userId)
.Select(user => user.Group)
.FirstOrDefaultAsync(cancellationToken);
if (group == null)
return Gone<UserGroup>();
return new AuthorityResponse<UserGroup>(group);
});
/// <inheritdoc />
public RequirementsGated<IQueryable<UserGroup>> Queryable(bool includeJoins)
=> new(
() => Flag(AdministrationRights.ReadUsers),
() => ValueTask.FromResult(QueryableImpl(includeJoins)));
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<UserGroup>> Create(string name, Models.PermissionSet? permissionSet, CancellationToken cancellationToken)
{
if (id != AuthenticationContext.User.GroupId && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers))
return Forbid<UserGroup>();
ArgumentNullException.ThrowIfNull(name);
return new(
() => Flag(AdministrationRights.WriteUsers),
async () =>
{
var totalGroups = await DatabaseContext
.Groups
.CountAsync(cancellationToken);
if (totalGroups >= generalConfigurationOptions.Value.UserGroupLimit)
return Conflict<UserGroup>(ErrorCode.UserGroupLimitReached);
UserGroup? userGroup;
if (includeJoins)
userGroup = await Queryable(true)
.Where(x => x.Id == id)
.FirstOrDefaultAsync(cancellationToken);
else
userGroup = await userGroupsDataLoader.LoadAsync(id, cancellationToken);
var modelPermissionSet = new Models.PermissionSet
{
AdministrationRights = permissionSet?.AdministrationRights ?? AdministrationRights.None,
InstanceManagerRights = permissionSet?.InstanceManagerRights ?? InstanceManagerRights.None,
};
if (userGroup == null)
return Gone<UserGroup>();
var dbGroup = new UserGroup
{
Name = name,
PermissionSet = modelPermissionSet,
};
return new AuthorityResponse<UserGroup>(userGroup);
DatabaseContext.Groups.Add(dbGroup);
await DatabaseContext.Save(cancellationToken);
Logger.LogInformation("Created new user group {groupName} ({groupId})", dbGroup.Name, dbGroup.Id);
return new AuthorityResponse<UserGroup>(
dbGroup,
HttpSuccessResponse.Created);
});
}
/// <inheritdoc />
public ValueTask<AuthorityResponse<UserGroup>> Read()
{
var group = AuthenticationContext.User!.Group;
if (group == null)
return ValueTask.FromResult(Gone<UserGroup>());
public RequirementsGated<AuthorityResponse<UserGroup>> Update(long id, string? newName, Models.PermissionSet? newPermissionSet, CancellationToken cancellationToken)
=> new(
() => Flag(AdministrationRights.WriteUsers),
async () =>
{
var currentGroup = await DatabaseContext
.Groups
.Where(x => x.Id == id)
.Include(x => x.PermissionSet)
.FirstOrDefaultAsync(cancellationToken);
return ValueTask.FromResult(new AuthorityResponse<UserGroup>(group));
}
if (currentGroup == default)
return Gone<UserGroup>();
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<UserGroup>(currentGroup);
});
/// <inheritdoc />
public IQueryable<UserGroup> Queryable(bool includeJoins)
public RequirementsGated<AuthorityResponse> DeleteEmpty(long id, CancellationToken cancellationToken)
=> new(
() => Flag(AdministrationRights.WriteUsers),
async () =>
{
var numDeleted = await DatabaseContext
.Groups
.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
.Where(x => x.Id == id)
.AnyAsync(cancellationToken);
return new(
groupExists
? new ErrorMessageResponse(ErrorCode.UserGroupNotEmpty)
: new ErrorMessageResponse(),
groupExists
? HttpFailureResponse.Conflict
: HttpFailureResponse.Gone);
});
/// <summary>
/// Get the <see cref="IQueryable{T}"/> <see cref="UserGroup"/>s.
/// </summary>
/// <param name="includeJoins">If <see cref="UserGroup.Users"/> and <see cref="UserGroup.PermissionSet"/> should be included.</param>
/// <returns>An <see cref="IQueryable{T}"/> of <see cref="UserGroup"/>s.</returns>
IQueryable<UserGroup> QueryableImpl(bool includeJoins)
{
var queryable = DatabaseContext
.Groups
.AsQueryable();
IQueryable<UserGroup> queryable = DatabaseContext
.Groups;
if (includeJoins)
queryable = queryable
@@ -123,92 +247,5 @@ namespace Tgstation.Server.Host.Authority
return queryable;
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<UserGroup>> 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<UserGroup>(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<UserGroup>(
dbGroup,
HttpSuccessResponse.Created);
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<UserGroup>> 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<UserGroup>();
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<UserGroup>(currentGroup);
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse> 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);
}
}
}
@@ -116,7 +116,6 @@ namespace Tgstation.Server.Host.Components.Chat.Commands
await databaseContextFactory.UseContext(
async db => results = await db
.RevisionInformations
.AsQueryable()
.Where(x => x.Instance!.Id == instance.Id && x.CommitSha == head)
.SelectMany(x => x.ActiveTestMerges!)
.Select(x => x.TestMerge)
@@ -9,6 +9,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Remora.Discord.API.Abstractions.Gateway.Commands;
using Remora.Discord.API.Abstractions.Gateway.Events;
@@ -72,9 +73,9 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
readonly IAssemblyInformationProvider assemblyInformationProvider;
/// <summary>
/// The <see cref="GeneralConfiguration"/> for the <see cref="DiscordProvider"/>.
/// The <see cref="GeneralConfiguration"/> <see cref="IOptionsMonitor{TOptions}"/> for the <see cref="DiscordProvider"/>.
/// </summary>
readonly GeneralConfiguration generalConfiguration;
readonly IOptionsMonitor<GeneralConfiguration> generalConfigurationOptions;
/// <summary>
/// The <see cref="ServiceProvider"/> containing Discord services.
@@ -141,18 +142,18 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="Provider"/>.</param>
/// <param name="assemblyInformationProvider">The value of <see cref="assemblyInformationProvider"/>.</param>
/// <param name="chatBot">The <see cref="ChatBot"/> for the <see cref="Provider"/>.</param>
/// <param name="generalConfiguration">The value of <see cref="generalConfiguration"/>.</param>
/// <param name="generalConfigurationOptions">The value of <see cref="generalConfigurationOptions"/>.</param>
public DiscordProvider(
IJobManager jobManager,
IAsyncDelayer asyncDelayer,
ILogger<DiscordProvider> logger,
IAssemblyInformationProvider assemblyInformationProvider,
ChatBot chatBot,
GeneralConfiguration generalConfiguration)
IOptionsMonitor<GeneralConfiguration> generalConfigurationOptions,
ChatBot chatBot)
: base(jobManager, asyncDelayer, logger, chatBot)
{
this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
this.generalConfiguration = generalConfiguration ?? throw new ArgumentNullException(nameof(generalConfiguration));
this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
mappedChannels = new List<ulong>();
connectDisconnectLock = new object();
@@ -924,7 +925,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
true),
EngineType.OpenDream => new EmbedField(
"OpenDream Version",
$"[{engineVersion.SourceSHA![..7]}]({generalConfiguration.OpenDreamGitUrl}/commit/{engineVersion.SourceSHA})",
$"[{engineVersion.SourceSHA![..7]}]({generalConfigurationOptions.CurrentValue.OpenDreamGitUrl}/commit/{engineVersion.SourceSHA})",
true),
_ => throw new InvalidOperationException($"Invaild EngineType: {engineVersion.Engine.Value}"),
};
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
using Meebey.SmartIrc4net;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
@@ -92,7 +93,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// <summary>
/// The <see cref="FileLoggingConfiguration"/> for the <see cref="IrcProvider"/>.
/// </summary>
readonly FileLoggingConfiguration loggingConfiguration;
readonly IOptionsMonitor<FileLoggingConfiguration> loggingConfigurationOptions;
/// <summary>
/// The <see cref="IrcFeatures"/> client.
@@ -117,18 +118,18 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="Provider"/>.</param>
/// <param name="assemblyInformationProvider">The <see cref="IAssemblyInformationProvider"/> to get the <see cref="IAssemblyInformationProvider.VersionString"/> from.</param>
/// <param name="chatBot">The <see cref="Models.ChatBot"/> for the <see cref="Provider"/>.</param>
/// <param name="loggingConfiguration">The <see cref="FileLoggingConfiguration"/> for the <see cref="Provider"/>.</param>
/// <param name="loggingConfigurationOptions">The value of <see cref="loggingConfigurationOptions"/>.</param>
public IrcProvider(
IJobManager jobManager,
IAsyncDelayer asyncDelayer,
ILogger<IrcProvider> logger,
IAssemblyInformationProvider assemblyInformationProvider,
Models.ChatBot chatBot,
FileLoggingConfiguration loggingConfiguration)
IAssemblyInformationProvider assemblyInformationProvider,
IOptionsMonitor<FileLoggingConfiguration> loggingConfigurationOptions)
: base(jobManager, asyncDelayer, logger, chatBot)
{
ArgumentNullException.ThrowIfNull(assemblyInformationProvider);
ArgumentNullException.ThrowIfNull(loggingConfiguration);
ArgumentNullException.ThrowIfNull(loggingConfigurationOptions);
var builder = chatBot.CreateConnectionStringBuilder();
if (builder == null || !builder.Valid || builder is not IrcConnectionStringBuilder ircBuilder)
@@ -143,7 +144,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
passwordType = ircBuilder.PasswordType;
assemblyInfo = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
this.loggingConfiguration = loggingConfiguration ?? throw new ArgumentNullException(nameof(loggingConfiguration));
this.loggingConfigurationOptions = loggingConfigurationOptions ?? throw new ArgumentNullException(nameof(loggingConfigurationOptions));
client = InstantiateClient();
@@ -758,7 +759,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
newClient.OnChannelMessage += Client_OnChannelMessage;
newClient.OnQueryMessage += Client_OnQueryMessage;
if (loggingConfiguration.ProviderNetworkDebug)
if (loggingConfigurationOptions.CurrentValue.ProviderNetworkDebug)
{
newClient.OnReadLine += (sender, e) => Logger.LogTrace("READ: {line}", e.Line);
newClient.OnWriteLine += (sender, e) => Logger.LogTrace("WRITE: {line}", e.Line);
@@ -36,14 +36,14 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
readonly ILoggerFactory loggerFactory;
/// <summary>
/// The <see cref="GeneralConfiguration"/> for the <see cref="ProviderFactory"/>.
/// The <see cref="GeneralConfiguration"/> <see cref="IOptionsMonitor{TOptions}"/> for the <see cref="ProviderFactory"/>.
/// </summary>
readonly GeneralConfiguration generalConfiguration;
readonly IOptionsMonitor<GeneralConfiguration> generalConfigurationOptions;
/// <summary>
/// The <see cref="FileLoggingConfiguration"/> for the <see cref="ProviderFactory"/>.
/// The <see cref="FileLoggingConfiguration"/> <see cref="IOptionsMonitor{TOptions}"/> for the <see cref="ProviderFactory"/>.
/// </summary>
readonly FileLoggingConfiguration loggingConfiguration;
readonly IOptionsMonitor<FileLoggingConfiguration> loggingConfigurationOptions;
/// <summary>
/// Initializes a new instance of the <see cref="ProviderFactory"/> class.
@@ -52,22 +52,22 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// <param name="assemblyInformationProvider">The value of <see cref="assemblyInformationProvider"/>.</param>
/// <param name="asyncDelayer">The value of <see cref="asyncDelayer"/>.</param>
/// <param name="loggerFactory">The value of <see cref="loggerFactory"/>.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="generalConfiguration"/>.</param>
/// <param name="loggingConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="loggingConfiguration"/>.</param>
/// <param name="generalConfigurationOptions">The value of <see cref="generalConfigurationOptions"/>.</param>
/// <param name="loggingConfigurationOptions">The value of <see cref="loggingConfigurationOptions"/>.</param>
public ProviderFactory(
IJobManager jobManager,
IAssemblyInformationProvider assemblyInformationProvider,
IAsyncDelayer asyncDelayer,
ILoggerFactory loggerFactory,
IOptions<GeneralConfiguration> generalConfigurationOptions,
IOptions<FileLoggingConfiguration> loggingConfigurationOptions)
IOptionsMonitor<GeneralConfiguration> generalConfigurationOptions,
IOptionsMonitor<FileLoggingConfiguration> 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));
generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
loggingConfiguration = loggingConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(loggingConfigurationOptions));
this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
this.loggingConfigurationOptions = loggingConfigurationOptions ?? throw new ArgumentNullException(nameof(loggingConfigurationOptions));
}
/// <inheritdoc />
@@ -80,16 +80,16 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
jobManager,
asyncDelayer,
loggerFactory.CreateLogger<IrcProvider>(),
assemblyInformationProvider,
settings,
loggingConfiguration),
assemblyInformationProvider,
loggingConfigurationOptions),
ChatProvider.Discord => new DiscordProvider(
jobManager,
asyncDelayer,
loggerFactory.CreateLogger<DiscordProvider>(),
assemblyInformationProvider,
settings,
generalConfiguration),
generalConfigurationOptions,
settings),
_ => throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Invalid ChatProvider: {0}", settings.Provider)),
};
}
@@ -63,7 +63,7 @@ namespace Tgstation.Server.Host.Components.Deployment
/// </summary>
/// <param name="dmbLock">The <see cref="DmbLock"/> to get a description of.</param>
/// <returns>A verbose description of <paramref name="dmbLock"/>.</returns>
static string GetFullLockDescriptor(DmbLock dmbLock) => $"{dmbLock.LockID} {dmbLock.Descriptor} (Created at {dmbLock.LockTime}){(dmbLock.KeptAlive ? " (RELEASED)" : String.Empty)}";
static string GetFullLockDescriptor(DmbLock dmbLock) => $"{dmbLock.LockID} {dmbLock.EngineVersion} {dmbLock.Descriptor} (Created at {dmbLock.LockTime}){(dmbLock.KeptAlive ? " (RELEASED)" : String.Empty)}";
/// <summary>
/// Initializes a new instance of the <see cref="DeploymentLockManager"/> class.
@@ -81,7 +81,7 @@ namespace Tgstation.Server.Host.Components.Deployment
readonly CancellationTokenSource cleanupCts;
/// <summary>
/// The <see cref="CancellationTokenSource"/> for <see cref="LogLockStates"/>.
/// The <see cref="CancellationTokenSource"/> for <see cref="LogLockStatesLoop"/>.
/// </summary>
readonly CancellationTokenSource lockLogCts;
@@ -207,7 +207,6 @@ namespace Tgstation.Server.Host.Components.Deployment
async (db) =>
cj = await db
.CompileJobs
.AsQueryable()
.Where(x => x.Job.Instance!.Id == metadata.Id)
.OrderByDescending(x => x.Job.StoppedAt)
.FirstOrDefaultAsync(cancellationToken));
@@ -224,7 +223,7 @@ namespace Tgstation.Server.Host.Components.Deployment
}
// we dont do CleanUnusedCompileJobs here because the watchdog may have plans for them yet
cleanupTask = Task.WhenAll(cleanupTask, LogLockStates());
cleanupTask = Task.WhenAll(cleanupTask, LogLockStatesLoop());
}
/// <inheritdoc />
@@ -277,7 +276,6 @@ namespace Tgstation.Server.Host.Components.Deployment
{
jobUidsToNotErase = (await db
.CompileJobs
.AsQueryable()
.Where(
x => x.Job.Instance!.Id == metadata.Id
&& jobIdsToSkip.Contains(x.Id!.Value))
@@ -331,6 +329,18 @@ namespace Tgstation.Server.Host.Components.Deployment
return provider.CompileJob;
}
/// <inheritdoc />
public void LogLockStates()
{
var builder = new StringBuilder();
lock (jobLockManagers)
foreach (var lockManager in jobLockManagers.Values)
lockManager.LogLockStats(builder);
logger.LogTrace("Periodic deployment log states report:{newLine}{report}", Environment.NewLine, builder);
}
/// <summary>
/// Gets a <see cref="IDmbProvider"/> and potentially the <see cref="DeploymentLockManager"/> for a given <see cref="CompileJob"/>.
/// </summary>
@@ -352,7 +362,6 @@ namespace Tgstation.Server.Host.Components.Deployment
await databaseContextFactory.UseContext(
async db => compileJob = await db
.CompileJobs
.AsQueryable()
.Where(x => x!.Id == compileJobId)
.Include(x => x.Job!)
.ThenInclude(x => x.StartedBy)
@@ -509,7 +518,7 @@ namespace Tgstation.Server.Host.Components.Deployment
async ValueTask DeleteCompileJobContent(string directory, CancellationToken cancellationToken)
{
// Then call the cleanup event, waiting here first
await eventConsumer.HandleEvent(EventType.DeploymentCleanup, new List<string> { ioManager.ResolvePath(directory) }, true, cancellationToken);
await eventConsumer.HandleEvent(EventType.DeploymentCleanup, new List<string> { ioManager.ResolvePath(directory) }, false, true, cancellationToken);
await ioManager.DeleteDirectory(directory, cancellationToken);
}
@@ -517,7 +526,7 @@ namespace Tgstation.Server.Host.Components.Deployment
/// Lock all <see cref="DeploymentLockManager"/>s states.
/// </summary>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
async Task LogLockStates()
async Task LogLockStatesLoop()
{
logger.LogTrace("Entering lock logging loop");
CancellationToken cancellationToken = lockLogCts.Token;
@@ -525,14 +534,7 @@ namespace Tgstation.Server.Host.Components.Deployment
while (!cancellationToken.IsCancellationRequested)
try
{
var builder = new StringBuilder();
lock (jobLockManagers)
foreach (var lockManager in jobLockManagers.Values)
lockManager.LogLockStats(builder);
logger.LogTrace("Periodic deployment log states report:{newLine}{report}", Environment.NewLine, builder);
LogLockStates();
await asyncDelayer.Delay(TimeSpan.FromMinutes(10), cancellationToken);
}
catch (OperationCanceledException ex)

Some files were not shown because too many files have changed in this diff Show More