diff --git a/.env.development.sample b/.env.development.sample
index 088320e61..b41b53694 100644
--- a/.env.development.sample
+++ b/.env.development.sample
@@ -19,8 +19,14 @@ TESTLAB_SOLAR_FACADES_OPEN_ID_CONNECT_CLIENT_SECRET=secret
IGSDB_API_TOKEN=secret
IGSDB_OPEN_ID_CONNECT_CLIENT_SECRET=secret
+EPEA_USER_ID=christoph.maurer@ise.fraunhofer.de
+EPEA_API_KEY=secret
+EPEA_OPEN_ID_CONNECT_CLIENT_SECRET=secret
+
OPEN_TELEMETRY_HOST=host.docker.internal
OPEN_TELEMETRY_GRPC_PORT=4017
+EMAIL_ADDRESS=christoph.maurer@ise.fraunhofer.de
+
SMTP_HOST=email
SMTP_PORT=25
diff --git a/.env.production.sample b/.env.production.sample
index 0fbc3b58e..c2e30f140 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -18,10 +18,16 @@ TESTLAB_SOLAR_FACADES_OPEN_ID_CONNECT_CLIENT_SECRET=
IGSDB_API_TOKEN=
IGSDB_OPEN_ID_CONNECT_CLIENT_SECRET=
+EPEA_USER_ID=christoph.maurer@ise.fraunhofer.de
+EPEA_API_KEY=
+EPEA_OPEN_ID_CONNECT_CLIENT_SECRET=
+
OPEN_TELEMETRY_HOST=host.docker.internal
OPEN_TELEMETRY_GRPC_PORT=4317
-SMTP_HOST=mail-mtaKA26.fraunhofer.de
+EMAIL_ADDRESS=christoph.maurer@ise.fraunhofer.de
+
+SMTP_HOST=mta01.mx.fraunhofer.de
SMTP_PORT=25
RELAY_SMTP_HOST=
RELAY_SMTP_PORT=25
diff --git a/.env.staging.sample b/.env.staging.sample
index f732c1e61..cfb07d415 100644
--- a/.env.staging.sample
+++ b/.env.staging.sample
@@ -18,11 +18,17 @@ TESTLAB_SOLAR_FACADES_OPEN_ID_CONNECT_CLIENT_SECRET=
IGSDB_API_TOKEN=
IGSDB_OPEN_ID_CONNECT_CLIENT_SECRET=
+EPEA_USER_ID=christoph.maurer@ise.fraunhofer.de
+EPEA_API_KEY=
+EPEA_OPEN_ID_CONNECT_CLIENT_SECRET=
+
OPEN_TELEMETRY_HOST=host.docker.internal
OPEN_TELEMETRY_GRPC_PORT=4317
+EMAIL_ADDRESS=christoph.maurer@ise.fraunhofer.de
+
SMTP_HOST=email
SMTP_PORT=25
-RELAY_SMTP_HOST=mail-mtaKA26.fraunhofer.de
+RELAY_SMTP_HOST=mta01.mx.fraunhofer.de
RELAY_SMTP_PORT=25
RELAY_ALLOWED_EMAILS=
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 13a1503c2..1a2b3fcd6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -119,6 +119,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
-
-
+## [v1.2.0] - 2026-06-30
+
+### Changed
+
+- **BREAKING**: Use `String` for cursors and `Int` for `first` and `last` parameters to conform to the Relay connection specification, simplify GraphQL filter input type names, and correct plural names ending before in `ys` and now in `ies` [#274](https://github.com/building-envelope-data/metabase/pull/274)
+
## [v1.1.1] - 2026-04-13
### Added
@@ -154,10 +160,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [v1.0.0] - 2022-02-28
-[Unreleased]: https://github.com/building-envelope-data/metabase/compare/v1.1.1...HEAD
-
+[Unreleased]: https://github.com/building-envelope-data/metabase/compare/v1.2.0...HEAD
+[v1.2.0]: https://github.com/building-envelope-data/metabase/compare/v1.1.1...v1.2.0
[v1.1.1]: https://github.com/building-envelope-data/metabase/compare/v1.1.0...v1.1.1
-
[v1.1.0]: https://github.com/building-envelope-data/metabase/compare/v1.0.0...v1.1.0
-
[v1.0.0]: https://github.com/building-envelope-data/metabase/compare/5e4c6579bc7b596502c0bc9318cf24435bed2c80...v1.0.0
diff --git a/README.md b/README.md
index b0853e348..7f0948b61 100644
--- a/README.md
+++ b/README.md
@@ -85,7 +85,7 @@ When doing so, please adhere to our
- OpenAPI reference at `https://www.local.buildingenvelopedata.org:${HTTPS_PORT}/openapi/docs/`,
- dummy email server at `https://www.local.buildingenvelopedata.org:${HTTPS_PORT}/email/`
(to view for example the confirmation email sent during registration),
- - OpenId Connect configuration at
+ - OpenID Connect configuration at
`https://www.local.buildingenvelopedata.org:${HTTPS_PORT}/.well-known/openid-configuration`,
- telemetry web frontend at `https://telemetry.local.buildingenvelopedata.org:${HTTPS_PORT}`,
- staging web frontend at `https://staging.local.buildingenvelopedata.org:${HTTPS_PORT}`,
@@ -275,17 +275,23 @@ and the pages following it.
some information on what the variables mean
- `NAME` is the name Docker project name, in particular, it is the prefix
of the Docker container names listed by `docker ps --all`;
+ - `ENVIRONMENT` is either `staging` or `production`;
+ - `TARGET` is the deployed tag or commit. It is set later by running
+ `./deploy.mk do TARGET=${TAG}`. The corresponding Docker images named
+ `${NAME}-backend:${TARGET}` and `${NAME}-frontend:${TARGET}` are built
+ on a build or development machine and pushed to the server later with
+ GNU Make targets from `./forge.mk`;
- `HOST` is the domain name with sub-domain of the deployment, in
- particular, it is used by the OpenId Connect provider and to make
+ particular, it is used by the OpenID Connect provider and to make
URLs absolute in emails sent for example when a user registers;
- `HTTP_PORT` is the HTTP port on which the reverse proxy NGINX listens
for requests;
- `BOOTSTRAP_USER_PASSWORD` is the password used by the database seeder
for the administrator account if there is none yet;
- - `OPEN_ID_CONNECT_CLIENT_SECRET` is the OpenId Connect client secret of
+ - `OPEN_ID_CONNECT_CLIENT_SECRET` is the OpenID Connect client secret of
the metabase as a client of itself as identity provider;
- `TESTLAB_SOLAR_FACADES_HOST` is the host with sub-domain of the TestLab
- Solar Façades used by the database seeder to add it as an OpenId
+ Solar Façades used by the database seeder to add it as an OpenID
Connect client;
- `TESTLAB_SOLAR_FACADES_OPEN_ID_CONNECT_CLIENT_SECRET` is the
corresponding client secret;
@@ -302,7 +308,17 @@ and the pages following it.
- `ln --symbolic ./docker.mk ./Makefile` and
- `ln --symbolic ./docker-compose.production.yaml ./docker-compose.yaml`.
- 1. Create the PostgreSQL database by running `./database.mk create`.
+ 1. Switch to the Git branch, tag, or commit you want to deploy by either running
+ `git switch ${BRANCH}`,
+ `git switch release/${TAG}`, or
+ `git switch --detach ${COMMIT_HASH}`,
+ where, for example, `${BRANCH}` is `develop` or `${TAG}` is `v1.0.0` or
+ `${COMMIT_HASH}` is `5e14d7d0858f26c00c82ab9c248cd750606a24b6`.
+
+ 1. Create the PostgreSQL database and schema by running
+ `./database.mk create migrate`.
+
+ 1. Build and start all services by running `make build up`.
### Creating a release
@@ -318,7 +334,7 @@ and the pages following it.
a new release" on
[Releases](https://github.com/building-envelope-data/metabase/releases).
1. Fetch the release branch by running `git fetch` and switch to it by running
- `git switch --detach release/v*.*.*`, where `*.*.*` is the version.
+ `git switch release/v*.*.*`, where `*.*.*` is the version.
1. If the databases have not diverged, then apply pending migrations with
`./database.mk migrate`. Otherwise, recreate the database by running
`./database.mk drop create migrate`.
@@ -330,11 +346,11 @@ and the pages following it.
### Deploying a release
1. Fetch the release branch by running `git fetch` and switch to it by running
- `git switch --detach release/${TAG}`, where `${TAG}` is the release tag to
+ `git switch release/${TAG}`, where `${TAG}` is the release tag to
be deployed, for example, `v1.0.0`.
1. Build and push the Docker images for the services `frontend` and `backend`
- services by running `./forge.mk all USER=cloud HOST=${IP}`, where `${IP}` is
- the server's IP address.
+ by running `./forge.mk all USER=cloud HOST=${IP}`, where `${IP}` is the
+ server's IP address.
1. Enter a shell on the production machine using `ssh`.
1. Navigate into `/app/production` by running `cd /app/production`.
1. Back up the production database by running
@@ -347,34 +363,57 @@ and the pages following it.
1. Deploy the new release in the staging environment by running
`./deploy.mk do TARGET=${TAG}`, where `${TAG}` is
the release tag to be deployed, for example, `v1.0.0`.
-1. If it fails _after_ the database backup was made, rollback to the previous
- state by running
- `./deploy.mk rollback`,
- figure out what went wrong, apply the necessary fixes to the codebase,
- create a new release, and try to deploy that release instead.
+1. If it fails rollback to the previous state by running
+ `./deploy.mk rollback`, figure out what went wrong, apply the necessary
+ fixes to the codebase, create a new release, and try to deploy that release
+ instead.
1. If it succeeds, deploy the new reverse proxy that handles sub-domains by
running `cd /app/machine && ./deploy.mk do` and test whether everything works
- as expected and if that is the case, continue. Note that in the
- staging environment sent emails can be viewed in the web browser under
- `https://staging.buildingenvelopedata.org/email/` and emails to addresses in
- the variable `RELAY_ALLOWED_EMAILS` in `./.env` are delivered to the
- respective inboxes (the variable's value is a comma separated list of email
- addresses). Note that in order for OpenId Connect to work as expected in
- staging, make sure that the redirect URIs use the sub-domain `staging`
- (instead of `www`) by entering `psql` with `./database.mk psql`, examining
- the output of the SQL statement
- `select * from metabase."OpenIddictApplications";`
- and if necessary executing SQL statements along the lines
- `update metabase."OpenIddictApplications" set "RedirectUris"='["https://staging.buildingenvelopedata.org/connect/callback/login/metabase"]', "PostLogoutRedirectUris"='["https://staging.buildingenvelopedata.org/connect/callback/logout/metabase"]' where "ClientId"='metabase';`
- and
- `update metabase."OpenIddictApplications" set "RedirectUris"='["https://staging.solarbuildingenvelopes.com/connect/callback/login/metabase"]', "PostLogoutRedirectUris"='["https://staging.solarbuildingenvelopes.com/connect/callback/logout/metabase"]' where "ClientId"='testlab-solar-facades';`
- And if you want the staging environment to forward queries to the staging
- environments of product-data databases, then examine the output of the SQL
- statement `select * from metabase.database;` and if necessary execute SQL
- statements along the lines
- `update metabase.database set "Locator"='https://staging.solarbuildingenvelopes.com/graphql/' where "Locator"='https://www.solarbuildingenvelopes.com/graphql/';`
- and
- `update metabase.database set "Locator"='https://igsdb-v2-staging.herokuapp.com/graphql/' where "Locator"='https://igsdb-v2.herokuapp.com/graphql/';`
+ as expected and if that is the case, continue.
+ - In the staging environment sent emails can be viewed in the web browser
+ under `https://staging.buildingenvelopedata.org/email/` and emails to
+ addresses in the variable `RELAY_ALLOWED_EMAILS` in `./.env` are delivered
+ to the respective inboxes (the variable's value is a comma separated list of
+ email addresses).
+
+ - If you want the staging environment to forward queries to the staging
+ environments of product-data databases, then examine the output of the SQL
+ statement `select * from metabase.database;` and if necessary execute SQL
+ statements along the lines
+
+ ```
+ update metabase.database set "Locator" = 'https://staging.solarbuildingenvelopes.com/graphql/' where "Locator" = 'https://www.solarbuildingenvelopes.com/graphql/';
+ update metabase.database set "Locator" = 'https://igsdb-v2-staging.herokuapp.com/graphql/' where "Locator" = 'https://igsdb-v2.herokuapp.com/graphql/';
+ ```
+
+ - In order for OpenID Connect to work in staging,
+ - replace `OPEN_ID_CONNECT_CLIENT_SECRET` in `/app/staging/.env` by the
+ respective value in `/app/production/.env` and run `make up` to recreate
+ Docker compose services which depend on that value. If you have a
+ staging deployment of a
+ [product-data database](https://github.com/building-envelope-data/database)
+ that communicates with the staging deployment of the metabase, then
+ replace `OPEN_ID_CONNECT_CLIENT_SECRET` there also. Both changes are
+ necessary to make the OpenID Connect clients in metabase and product-data
+ database use the client secret whose hash value is stored in the metabase;
+
+ - make sure that the redirect URIs and resource permissions use the
+ sub-domain `staging` (instead of `www`) by entering `psql` with
+ `./database.mk psql`, examining the output of the SQL statement
+ `select * from metabase."OpenIddictApplications";`
+ and, if necessary, executing SQL statements along the lines
+
+ ```
+ update metabase."OpenIddictApplications" set "RedirectUris" = '["https://staging.buildingenvelopedata.org/connect/callback/login/metabase"]', "PostLogoutRedirectUris" = '["https://staging.buildingenvelopedata.org/connect/callback/logout/metabase"]' where "ClientId" = 'metabase';
+ update metabase."OpenIddictApplications" set "RedirectUris" = '["https://staging.solarbuildingenvelopes.com/connect/callback/login/metabase"]', "PostLogoutRedirectUris" = '["https://staging.solarbuildingenvelopes.com/connect/callback/logout/metabase"]' where "ClientId" = 'testlab-solar-facades';
+ ```
+
+ and
+
+ ```
+ update metabase."OpenIddictApplications" set "Permissions" = ("Permissions"::jsonb - 'rsrc:https://www.buildingenvelopedata.org/graphql/' || '"rsrc:https://staging.buildingenvelopedata.org/graphql/"'::jsonb)::text where "ClientId" = 'metabase';
+ ```
+
1. Change to the production environment by running `cd /app/production`.
1. Adapt the environment file `./.env` if necessary by comparing it with the
`./.env.production.sample` file of the release to be deployed.
@@ -489,7 +528,7 @@ calculations.
The access right management of the product data network is based on the
framework [OpenID Connect](https://openid.net/developers/how-connect-works/).
The general idea is that users and applications can authenticate at the
-metabase (OpenId Connect Provider) and receive an access token (security
+metabase (OpenID Connect Provider) and receive an access token (security
credentials). When an application sends queries and mutations to product data
servers, it can attach the token. The product data server receives the token
and determines the access rights accordingly (authorization) using information
@@ -540,11 +579,11 @@ data server, please
to your institution. For `${UUID_OF_YOUR_INSTITUTION}` please use the UUID
which your institution has received when it was created.
-1. equip your product data server with an OpenId Connect Client partly configuring
+1. equip your product data server with an OpenID Connect Client partly configuring
it via OpenID Connect Discovery using the [Well-Known Configuration
Endpoint](https://www.buildingenvelopedata.org/.well-known/openid-configuration).
-When adding an OpenId Connect Application, you need to make various decisions:
+When adding an OpenID Connect Application, you need to make various decisions:
[Which OAuth 2.0 Flow Should
I Use?](https://auth0.com/docs/get-started/authentication-and-authorization-flow/which-oauth-2-0-flow-should-i-use)
We support the [Authorization Code Flow with Pushed Authorization Requests
diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props
index 7538c469d..1de6f3cae 100644
--- a/backend/Directory.Build.props
+++ b/backend/Directory.Build.props
@@ -13,6 +13,8 @@
true
latest-Recommended
true
+
+ true
diff --git a/backend/Dockerfile.development b/backend/Dockerfile.development
index f0c306079..1f0628a7f 100644
--- a/backend/Dockerfile.development
+++ b/backend/Dockerfile.development
@@ -1,4 +1,4 @@
-# syntax=docker/dockerfile:1.21
+# syntax=docker/dockerfile:1.23
# check=error=true;skip=SecretsUsedInArgOrEnv
# Available versions are listed on https://hub.docker.com/r/docker/dockerfile
@@ -144,6 +144,28 @@ RUN \
--force \
/var/lib/apt/lists/*
+# for available versions see the tags on https://github.com/stripe/pg-schema-diff
+ENV PG_SCHEMA_DIFF_VERSION=v1.0.5
+RUN \
+ apt-get update && \
+ apt-get install \
+ --assume-yes \
+ --no-install-recommends \
+ golang-go && \
+ go install \
+ github.com/stripe/pg-schema-diff/cmd/pg-schema-diff@${PG_SCHEMA_DIFF_VERSION} && \
+ cp /root/go/bin/pg-schema-diff /usr/local/bin/ && \
+ rm --recursive --force \
+ /root/go \
+ /root/.cache && \
+ apt-get purge \
+ --assume-yes \
+ golang-go && \
+ rm \
+ --recursive \
+ --force \
+ /var/lib/apt/lists/*
+
ENV HOME=/home/me
RUN mkdir --parents ${HOME}/app \
&& chown \
diff --git a/backend/Dockerfile.production b/backend/Dockerfile.production
index 1eb05b61e..787aede3e 100644
--- a/backend/Dockerfile.production
+++ b/backend/Dockerfile.production
@@ -1,4 +1,4 @@
-# syntax=docker/dockerfile:1.21
+# syntax=docker/dockerfile:1.23
# check=error=true
# Available versions are listed on https://hub.docker.com/r/docker/dockerfile
diff --git a/backend/Makefile b/backend/Makefile
index c5bf26845..88a177e4f 100644
--- a/backend/Makefile
+++ b/backend/Makefile
@@ -7,7 +7,7 @@ MAKEFLAGS += --warn-undefined-variables
# Taken from https://www.client9.com/self-documenting-makefiles/
help : ## Print this help
- @awk -F ':|##' '/^[^\t].+?:.*?##/ {\
+ @awk -F ':.*?## ' '/^[^\t].+?:.*?##/ {\
printf "\033[36m%-30s\033[0m %s\n", $$1, $$NF \
}' $(MAKEFILE_LIST)
.PHONY : help
@@ -22,7 +22,7 @@ help : ## Print this help
# See https://learn.microsoft.com/en-us/dotnet/core/testing/microsoft-testing-platform-run-and-debug?tabs=dotnetcli
list : ARGS =
-list : ## List tests, for example, `make list-tests ARGS='--filter "FullyQualifiedName~LoggedInUser_CreatesComponent"'`
+list : ## List tests, for example, `make list ARGS='--filter "FullyQualifiedName~LoggedInUser_CreatesComponent"'`
dotnet run \
--project ./test/*.Tests.csproj \
--list-tests \
@@ -88,7 +88,7 @@ analyze : ## Analyze with Roslynator
# https://www.jetbrains.com/help/resharper/ReSharper_Command_Line_Tools.html#install-and-use-resharper-command-line-tools-as-net-core-tools
inspect : ## Inspect with ReSharper, for example, `make inspect OUTPUT=./inspection.xml`
- dotnet tool run jb inspectcode --build ./*.sln -o ${OUTPUT}
+ dotnet tool run jb inspectcode --build --output "${OUTPUT}" ./*.sln
.PHONY : inspect
editorconfig : ## Debug editorconfig
@@ -171,13 +171,9 @@ dedup : ## Dedeuplicate code lines matching the pattern `${PATTERN}`, for exampl
}"
.PHONY : dedup
-# See https://github.com/hasura/graphqurl#export-schema
-schema : ## Fetch GraphQL schema using `hasura/graphqurl`
- gq \
- http://backend:8080/graphql/ \
- --introspect \
- > ./schema.graphql
-.PHONY : schema
+check-database : ## Check whether there have been any model changes made since the last migration was added and whether the C# domain model matches the PostgreSQL database schema
+ $(MAKE) test ARGS='--filter "FullyQualifiedName~EnsureMigrationsAreUpToDate|FullyQualifiedName~EnsureDomainModelMatchesDatabaseSchema"'
+.PHONY : check-database
has-pending-model-changes : ## Check whether there have been any model changes made since the last migration was added
dotnet ef \
diff --git a/backend/dotnet-tools.json b/backend/dotnet-tools.json
index 6761c3045..1cf679f35 100644
--- a/backend/dotnet-tools.json
+++ b/backend/dotnet-tools.json
@@ -24,14 +24,14 @@
"rollForward": false
},
"dotnet-ef": {
- "version": "10.0.5",
+ "version": "10.0.9",
"commands": [
"dotnet-ef"
],
"rollForward": false
},
"dotnet-script": {
- "version": "2.0.0",
+ "version": "2.0.1",
"commands": [
"dotnet-script"
],
@@ -66,7 +66,7 @@
"rollForward": false
},
"jetbrains.resharper.globaltools": {
- "version": "2025.3.3",
+ "version": "2026.1.3",
"commands": [
"jb"
],
@@ -87,7 +87,7 @@
"rollForward": false
},
"dotnet-outdated-tool": {
- "version": "4.7.1",
+ "version": "4.8.1",
"commands": [
"dotnet-outdated"
],
diff --git a/backend/src/AppSettings.cs b/backend/src/AppSettings.cs
index ad018b9c1..920878b25 100644
--- a/backend/src/AppSettings.cs
+++ b/backend/src/AppSettings.cs
@@ -1,6 +1,7 @@
// Inspired by https://weblog.west-wind.com/posts/2017/dec/12/easy-configuration-binding-in-aspnet-core-revisited
using System;
+using Npgsql;
namespace Metabase;
@@ -11,12 +12,16 @@ public sealed record AppSettings
public string Host { get; init; } = "";
public string Subdomain { private get; init; } = "";
public Uri Uri => new($"https://{Subdomain}.{Host}", UriKind.Absolute);
+ public Uri NonSubdomainUri => new Uri($"https://{Host}", UriKind.Absolute);
public Uri GraphQlEndpoint => new UriBuilder(Uri) { Path = GraphQlPathSegment }.Uri;
public string BootstrapUserPassword { get; init; } = "";
public string OpenIdConnectClientSecret { get; init; } = "";
+ public string EmailAddress { get; init; } = "";
+
public TestlabSolarFacadesSettings TestlabSolarFacades { get; init; } = new();
public IgsdbSettings Igsdb { get; init; } = new();
+ public EpeaSettings Epea { get; init; } = new();
public LoggingSettings Logging { get; init; } = new();
public EmailSettings Email { get; init; } = new();
public DatabaseSettings Database { get; init; } = new();
@@ -35,6 +40,13 @@ public sealed record IgsdbSettings
public string ApiToken { get; init; } = "";
};
+ public sealed record EpeaSettings
+ {
+ public string OpenIdConnectClientSecret { get; init; } = "";
+ public string UserId { get; init; } = "";
+ public string ApiKey { get; init; } = "";
+ };
+
public sealed record LoggingSettings
{
public bool EnableSensitiveDataLogging { get; init; }
@@ -54,7 +66,19 @@ public sealed record DatabaseSettings
public string UserName { get; init; } = "";
public string Password { get; init; } = "";
public string SchemaName { get; init; } = "";
- };
+
+ public string ConnectionString(string? customDatabaseName = null)
+ {
+ var connectionStringBuilder = new NpgsqlConnectionStringBuilder();
+ connectionStringBuilder.Host = Host;
+ connectionStringBuilder.Port = Port;
+ connectionStringBuilder.Database = customDatabaseName ?? Name;
+ connectionStringBuilder.Username = UserName;
+ connectionStringBuilder.Password = Password;
+ connectionStringBuilder.MaxPoolSize = 90;
+ return connectionStringBuilder.ConnectionString;
+ }
+ }
public sealed record OpenTelemetrySettings
{
diff --git a/backend/src/Authentication/AuthenticationConstants.cs b/backend/src/Authentication/AuthenticationConstants.cs
index 64e85fa6a..6f956803e 100644
--- a/backend/src/Authentication/AuthenticationConstants.cs
+++ b/backend/src/Authentication/AuthenticationConstants.cs
@@ -1,5 +1,3 @@
-using System.Collections.Immutable;
-
namespace Metabase.Authentication;
internal static class AuthenticationConstants
diff --git a/backend/src/Authentication/AuthenticationHandler.cs b/backend/src/Authentication/AuthenticationHandler.cs
index 88924d810..c4ee8d384 100644
--- a/backend/src/Authentication/AuthenticationHandler.cs
+++ b/backend/src/Authentication/AuthenticationHandler.cs
@@ -13,6 +13,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
+using NodaTime;
using OpenIddict.Abstractions;
using OpenIddict.Client;
using OpenIddict.Client.AspNetCore;
@@ -54,6 +55,7 @@ string userId
}
public sealed class AuthenticationHandler(
+ IClock clock,
UserManager userManager,
OpenIddictClientService openIddictClientService,
ILogger logger
@@ -184,7 +186,7 @@ CancellationToken cancellationToken
var expirationDate = await GetAccessTokenExpirationDateAsync(user, providerName);
if (accessToken is not null
&& expirationDate is not null
- && TimeProvider.System.GetUtcNow() <= expirationDate?.Subtract(OpenIdConnectConstants.AccessAndIdentityTokenLifetime.Divide(3))
+ && clock.GetUtcNow().ToDateTimeOffset() <= expirationDate?.Subtract(OpenIdConnectConstants.AccessAndIdentityTokenLifetime.Divide(3))
)
{
return accessToken;
@@ -214,7 +216,7 @@ CancellationToken cancellationToken
RefreshToken: refreshTokenAuthenticationResult.RefreshToken
)
);
- if (errors.Count >= 1)
+ if (errors.Count > 0)
{
logger.FailedToSetAuthenticationTokens(
string.Join(
@@ -278,4 +280,4 @@ CancellationToken cancellationToken
// `OpenIddictBuilder#AddValidation`.
return await httpContext.AuthenticateAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
}
-}
\ No newline at end of file
+}
diff --git a/backend/src/Authorization/ApprovalAuthorization.cs b/backend/src/Authorization/ApprovalAuthorization.cs
index f0935f850..aaa9bb86b 100644
--- a/backend/src/Authorization/ApprovalAuthorization.cs
+++ b/backend/src/Authorization/ApprovalAuthorization.cs
@@ -24,11 +24,11 @@ CancellationToken cancellationToken
claimsPrincipal,
async user => (await Context.InstitutionRepresentatives.AsQueryable()
.SingleOrDefaultAsync(
- x => x.UserId == user.Id,
+ _ => _.UserId == user.Id,
cancellationToken
)) is not null,
application => Task.FromResult(false),
cancellationToken
);
}
-}
\ No newline at end of file
+}
diff --git a/backend/src/Authorization/AuthorizationPolicies.cs b/backend/src/Authorization/AuthorizationPolicies.cs
index 6f69a50e8..6240e6d59 100644
--- a/backend/src/Authorization/AuthorizationPolicies.cs
+++ b/backend/src/Authorization/AuthorizationPolicies.cs
@@ -7,6 +7,7 @@ public static class AuthorizationPolicies
public const string WriteScopePolicy = "WriteScope";
public const string AdministrateScopePolicy = "AdministrateScope";
public const string VerifyScopePolicy = "VerifyScope";
+ public const string SupportScopePolicy = "SupportScope";
public const string ManageDatabaseScopePolicy = "ManageDatabaseScope";
public const string ManageGnuPgScopePolicy = "ManageGnuPgScope";
public const string ManageInstitutionRepresentativeScopePolicy = "ManageInstitutionRepresentativeScope";
diff --git a/backend/src/Authorization/CommonAuthorization.cs b/backend/src/Authorization/CommonAuthorization.cs
index 21b36d645..571e80518 100644
--- a/backend/src/Authorization/CommonAuthorization.cs
+++ b/backend/src/Authorization/CommonAuthorization.cs
@@ -1,10 +1,11 @@
using System;
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
+using HotChocolate;
+using HotChocolate.Resolvers;
using Metabase.Data;
using Metabase.Data.OpenIdConnect;
using Metabase.Enumerations;
@@ -21,38 +22,62 @@ public abstract class CommonAuthorization(
IDbContextFactory dbContextFactory,
UserManager userManager,
OpenIddictApplicationManager applicationManager
- )
+)
+: IDisposable, IAsyncDisposable
{
- protected ApplicationDbContext Context { get => dbContextFactory.CreateDbContext(); }
+ // This is the same code that HotChocolate returns when autheorization via the attribute `[Authorize(Policy = ...)]` fails.
+ private const string UNAUTHORIZED_CODE = "AUTH_NOT_AUTHENTICATED";
+
+ protected ApplicationDbContext Context { get; } = dbContextFactory.CreateDbContext();
protected UserManager UserManager { get; } = userManager;
protected OpenIddictApplicationManager ApplicationManager { get; } = applicationManager;
- internal const string ClientSubjectPrefix = "client:";
+ // [Implement a DisposeAsync method](https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-disposeasync)
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await DisposeAsyncCore();
+ Dispose(false);
+ GC.SuppressFinalize(this);
+ }
- public async Task SwitchUserOrApplicationAsync(
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ Context.Dispose();
+ }
+ }
+
+ protected virtual ValueTask DisposeAsyncCore()
+ {
+ return Context.DisposeAsync();
+ }
+
+ public Task SwitchUserOrApplicationAsync(
ClaimsPrincipal claimsPrincipal,
Func> handleUser,
Func> handleApplication,
CancellationToken cancellationToken
)
{
- var userOrPrefixedClientId = claimsPrincipal.GetClaim(Claims.Subject);
- // Note that a user ID is a UUID and thus cannot start with the client-subject prefix.
- if (userOrPrefixedClientId is not null
- && userOrPrefixedClientId.StartsWith(ClientSubjectPrefix, ignoreCase: false, culture: CultureInfo.InvariantCulture)
- )
- {
- var clientId = userOrPrefixedClientId[ClientSubjectPrefix.Length..];
- return await handleApplication(
+ return IOpenIdConnectSubject.SwitchSubjectAsync(
+ claimsPrincipal.GetClaim(Claims.Subject),
+ async (_) => await handleUser(
+ await GetUserAsync(claimsPrincipal)
+ ),
+ async (clientId) => await handleApplication(
await ApplicationManager.FindByClientIdAsync(clientId, cancellationToken)
- );
- }
- else
- {
- return await handleUser(
+ ),
+ async () => await handleUser(
await GetUserAsync(claimsPrincipal)
- );
- }
+ )
+ );
}
protected Task AuthorizeAsync(
@@ -87,6 +112,19 @@ Guid userId
return user.Id == userId;
}
+ public Task CanAdministrate(
+ ClaimsPrincipal claimsPrincipal,
+ CancellationToken cancellationToken
+ )
+ {
+ return AuthorizeAsync(
+ claimsPrincipal,
+ user => CanAdministrate(user, claimsPrincipal),
+ application => Task.FromResult(false),
+ cancellationToken
+ );
+ }
+
public async Task CanAdministrate(
User user,
ClaimsPrincipal claimsPrincipal
@@ -113,6 +151,19 @@ ClaimsPrincipal claimsPrincipal
);
}
+ public async Task CanSupport(
+ User user,
+ ClaimsPrincipal claimsPrincipal
+ )
+ {
+ return
+ claimsPrincipal.HasScope(OpenIdConnectScope.SupportApiScope)
+ && await IsInRole(
+ user,
+ UserRole.SUPPORTER
+ );
+ }
+
private Task IsInRole(
User user,
UserRole role
@@ -289,4 +340,17 @@ await context.InstitutionRepresentatives.AsNoTracking()
.SingleOrDefaultAsync(cancellationToken);
return wrappedManagerRole?.Role;
}
+
+ public void ReportUnauthorizedError(
+ IResolverContext resolverContext
+ )
+ {
+ resolverContext.ReportError(
+ ErrorBuilder.New()
+ .SetCode(UNAUTHORIZED_CODE)
+ .SetPath(resolverContext.Path)
+ .SetMessage($"The current user is not authorized to access this resource.")
+ .Build()
+ );
+ }
}
\ No newline at end of file
diff --git a/backend/src/Authorization/CommonComponentAuthorization.cs b/backend/src/Authorization/CommonComponentAuthorization.cs
index ecc9b95b4..6b67add05 100644
--- a/backend/src/Authorization/CommonComponentAuthorization.cs
+++ b/backend/src/Authorization/CommonComponentAuthorization.cs
@@ -25,7 +25,7 @@ CancellationToken cancellationToken
{
var wrappedManagerId =
await Context.Components.AsNoTracking()
- .Where(x => x.Id == componentId)
+ .Where(_ => _.Id == componentId)
.Select(x => new { x.ManagerId })
.SingleOrDefaultAsync(cancellationToken);
if (wrappedManagerId is null)
@@ -43,7 +43,7 @@ protected Task BelongsToVerifiedComponentManager(
CancellationToken cancellationToken
)
{
- return Context.Methods.AsNoTracking()
+ return Context.Components.AsNoTracking()
.Where(component => component.Id == componentId)
.Where(component => component.Manager != null && component.Manager.State == InstitutionState.VERIFIED)
.Where(component => component.Manager != null && (
diff --git a/backend/src/Authorization/CommonMethodAuthorization.cs b/backend/src/Authorization/CommonMethodAuthorization.cs
index facc7b0b7..b7db289af 100644
--- a/backend/src/Authorization/CommonMethodAuthorization.cs
+++ b/backend/src/Authorization/CommonMethodAuthorization.cs
@@ -25,7 +25,7 @@ CancellationToken cancellationToken
{
var wrappedManagerId =
await Context.Methods.AsNoTracking()
- .Where(x => x.Id == methodId)
+ .Where(_ => _.Id == methodId)
.Select(x => new { x.ManagerId })
.SingleOrDefaultAsync(cancellationToken);
if (wrappedManagerId is null)
@@ -53,4 +53,4 @@ CancellationToken cancellationToken
))
.AnyAsync(cancellationToken);
}
-}
\ No newline at end of file
+}
diff --git a/backend/src/Authorization/DataFormatAuthorization.cs b/backend/src/Authorization/DataFormatAuthorization.cs
index 8c73411bb..728b1ee83 100644
--- a/backend/src/Authorization/DataFormatAuthorization.cs
+++ b/backend/src/Authorization/DataFormatAuthorization.cs
@@ -77,7 +77,7 @@ CancellationToken cancellationToken
{
return (
await Context.DataFormats.AsNoTracking()
- .Where(x => x.Id == dataFormatId)
+ .Where(_ => _.Id == dataFormatId)
.Select(x => new { x.ManagerId })
.SingleOrDefaultAsync(cancellationToken)
)?.ManagerId;
@@ -99,4 +99,4 @@ CancellationToken cancellationToken
))
.AnyAsync(cancellationToken);
}
-}
\ No newline at end of file
+}
diff --git a/backend/src/Authorization/OpenIdConnectAuthorization.cs b/backend/src/Authorization/OpenIdConnectAuthorization.cs
index 859b4f67a..03afaa43c 100644
--- a/backend/src/Authorization/OpenIdConnectAuthorization.cs
+++ b/backend/src/Authorization/OpenIdConnectAuthorization.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading;
@@ -14,7 +15,9 @@ namespace Metabase.Authorization;
public sealed class OpenIdConnectAuthorization(
IDbContextFactory dbContextFactory,
UserManager userManager,
- OpenIddictApplicationManager applicationManager
+ OpenIddictApplicationManager applicationManager,
+ OpenIddictAuthorizationManager authorizationManager,
+ OpenIddictTokenManager tokenManager
) : CommonAuthorization(dbContextFactory, userManager, applicationManager)
{
internal Task IsAuthorizedToManageOpenIdConnect(
@@ -74,7 +77,6 @@ CancellationToken cancellationToken
internal async Task IsAuthorizedToManageAuthorization(
ClaimsPrincipal claimsPrincipal,
Guid authorizationId,
- OpenIddictAuthorizationManager authorizationManager,
CancellationToken cancellationToken
)
{
@@ -97,17 +99,15 @@ CancellationToken cancellationToken
internal Task IsAuthorizedToManageTokensOfAuthorization(
ClaimsPrincipal claimsPrincipal,
Guid authorizationId,
- OpenIddictAuthorizationManager authorizationManager,
CancellationToken cancellationToken
)
{
- return IsAuthorizedToManageAuthorization(claimsPrincipal, authorizationId, authorizationManager, cancellationToken);
+ return IsAuthorizedToManageAuthorization(claimsPrincipal, authorizationId, cancellationToken);
}
internal async Task IsAuthorizedToManageToken(
ClaimsPrincipal claimsPrincipal,
Guid tokenId,
- OpenIddictTokenManager tokenManager,
CancellationToken cancellationToken
)
{
@@ -133,4 +133,63 @@ CancellationToken cancellationToken
)
.AnyAsync(cancellationToken);
}
+
+ internal async Task> AuthorizedConsentTypes(
+ ClaimsPrincipal claimsPrincipal,
+ CancellationToken cancellationToken
+ )
+ {
+ return await CanAdministrate(claimsPrincipal, cancellationToken)
+ ? Enum.GetValues().ToList().AsReadOnly()
+ : [GraphQl.OpenIdConnect.Applications.OpenIdConnectConsentType.EXPLICIT];
+ }
+
+ internal static async Task> AuthorizedEndpoints(
+ ClaimsPrincipal claimsPrincipal,
+ CancellationToken cancellationToken
+ )
+ {
+ return Enum.GetValues().ToList().AsReadOnly();
+ }
+
+ internal async Task> AuthorizedGrantTypes(
+ ClaimsPrincipal claimsPrincipal,
+ CancellationToken cancellationToken
+ )
+ {
+ return await CanAdministrate(claimsPrincipal, cancellationToken)
+ ? Enum.GetValues().ToList().AsReadOnly()
+ : Enum.GetValues()
+ .Where(_ =>
+ _ is not GraphQl.OpenIdConnect.Applications.OpenIdConnectGrantType.TOKEN_EXCHANGE
+ )
+ .ToList()
+ .AsReadOnly();
+ }
+
+ internal static async Task> AuthorizedResponseTypes(
+ ClaimsPrincipal claimsPrincipal,
+ CancellationToken cancellationToken
+ )
+ {
+ return Enum.GetValues().ToList().AsReadOnly();
+ }
+
+ internal async Task> AuthorizedScopes(
+ ClaimsPrincipal claimsPrincipal,
+ CancellationToken cancellationToken
+ )
+ {
+ return await CanAdministrate(claimsPrincipal, cancellationToken)
+ ? Enum.GetValues().ToList().AsReadOnly()
+ : Enum.GetValues()
+ .Where(_ =>
+ _ is not GraphQl.OpenIdConnect.OpenIdConnectScope.ADMINISTRATE_API
+ && _ is not GraphQl.OpenIdConnect.OpenIdConnectScope.SUPPORT_API
+ && _ is not GraphQl.OpenIdConnect.OpenIdConnectScope.MANAGE_USER_API
+ )
+ .ToList()
+ .AsReadOnly();
+ }
+
}
\ No newline at end of file
diff --git a/backend/src/Authorization/UserAuthorization.cs b/backend/src/Authorization/UserAuthorization.cs
index 0d0324247..f7d702646 100644
--- a/backend/src/Authorization/UserAuthorization.cs
+++ b/backend/src/Authorization/UserAuthorization.cs
@@ -8,7 +8,6 @@
using Metabase.Data.OpenIdConnect;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
-using OpenIddict.Abstractions;
using OpenIddict.Core;
using UserRole = Metabase.Enumerations.UserRole;
@@ -71,6 +70,8 @@ CancellationToken cancellationToken
await CanAdministrate(user, claimsPrincipal),
UserRole.VERIFIER =>
await CanVerify(user, claimsPrincipal),
+ UserRole.SUPPORTER =>
+ await CanSupport(user, claimsPrincipal),
_ => throw new ArgumentOutOfRangeException(nameof(role), $"Unknown role `{role}.`")
},
application => Task.FromResult(false),
diff --git a/backend/src/Configuration/AuthConfiguration.cs b/backend/src/Configuration/AuthConfiguration.cs
index 37ff2b1d9..8f933decd 100644
--- a/backend/src/Configuration/AuthConfiguration.cs
+++ b/backend/src/Configuration/AuthConfiguration.cs
@@ -11,6 +11,7 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+using NodaTime;
using OpenIddict.Abstractions;
using OpenIddict.Client;
using Quartz;
@@ -28,6 +29,7 @@ public static class AuthConfiguration
{ AuthorizationPolicies.WriteScopePolicy, OpenIdConnectScope.WriteApiScope },
{ AuthorizationPolicies.AdministrateScopePolicy, OpenIdConnectScope.AdministrateApiScope },
{ AuthorizationPolicies.VerifyScopePolicy, OpenIdConnectScope.VerifyApiScope },
+ { AuthorizationPolicies.SupportScopePolicy, OpenIdConnectScope.SupportApiScope },
{ AuthorizationPolicies.ManageDatabaseScopePolicy, OpenIdConnectScope.ManageDatabaseApiScope },
{ AuthorizationPolicies.ManageGnuPgScopePolicy, OpenIdConnectScope.ManageGnuPgApiScope },
{ AuthorizationPolicies.ManageInstitutionRepresentativeScopePolicy, OpenIdConnectScope.ManageInstitutionRepresentativeApiScope },
@@ -35,7 +37,7 @@ public static class AuthConfiguration
{ AuthorizationPolicies.ManageUserScopePolicy, OpenIdConnectScope.ManageUserApiScope },
};
- private static void BootstrapCertificates()
+ private static void BootstrapCertificates(IClock clock)
{
using var store = new X509Store(OpenIdConnectConstants.CertificateStoreName, OpenIdConnectConstants.CertificateStoreLocation);
try
@@ -51,11 +53,12 @@ private static void BootstrapCertificates()
distinguishedName,
validOnly: true
);
- if (certificates.Count == 0)
+ if (certificates.Count is 0)
{
store.Add(
JwtSigningAndEncryptionCertificateRotationJob.CreateSigningCertificate(
- distinguishedName
+ distinguishedName,
+ clock
)
);
}
@@ -70,11 +73,12 @@ private static void BootstrapCertificates()
distinguishedName,
validOnly: true
);
- if (certificates.Count == 0)
+ if (certificates.Count is 0)
{
store.Add(
JwtSigningAndEncryptionCertificateRotationJob.CreateEncryptionCertificate(
- distinguishedName
+ distinguishedName,
+ clock
)
);
}
@@ -113,10 +117,11 @@ private static IEnumerable FindCertificates(string distinguish
public static void ConfigureServices(
IServiceCollection services,
IWebHostEnvironment environment,
- AppSettings appSettings
+ AppSettings appSettings,
+ IClock clock
)
{
- BootstrapCertificates();
+ BootstrapCertificates(clock);
services.AddScoped();
services.AddScoped();
ConfigureIdentityServices(services);
@@ -459,25 +464,24 @@ AppSettings appSettings
// Note: when issuing access tokens used by third-party APIs you don't own, you
// can disable access token encryption: _.DisableAccessTokenEncryption();
// Note: To decrypt a JWT token, like an application code or an access token,
- // you can execute ``` var jwt =
- // "eyJhbGciOiJBMjU2S1ciLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIiwidHlwIjoiYXQrand0In0.tn0jqCR5-01c_SDRwex6sPBNl1Vl1KSRA5Zs2UoXri9F4tG4v9B6qbLmxzsfkd0rLe55BozRV8VCChsZAt_mCZfoVGOYewwP.ogGZQ5Q2p7Yue7D6zPJlSQ.uNX1Qs9R30vZxAPj-LuJSwWnisoKHQ5qNC_K9WvA0JDSCS_orw6TsboSVCe5b_aUg3JvrkJX_Ir0c-bFMf0HVhOVNy1UJwxo9zegJOsm7MybPzK58H4ubt_PRnrSueLgnQX8aDcjbM38Imy3RN6a3r3aKawdWGcyA23sIH8XnVmGk0lDB_PqFrFE7x2MmG4fyVJINoki441UI-7x0sLFUi4o98Z-2vTFuCd9cLRY5LAeb0ZIuWwOI7dv2Q54w7uV765kHS3VIPtupzSSXgQmfPBJOzDeV_-sCZGwUuC0jL8x1vJw573fejPwpPmXj3EKgzXgbGfAHmjoIzkNvcfr--Dy7O8WxLjSERoBgW45Tq0xcCLL9Vx4JGWehOw1jY-KaKIzfjUW9CTSwLgWdhqonetAULZFRJAYOEJ8PtP49jXlvkdjmavRXaX2UD8FD5TUx30TkGQ_xyuo6HvokURQqIYILZiL6R_kVqh7kwUnj4dgBqni-56M1GYFuX2UzynagL6c6t4OlO7RhQJpgz1pzmGNdEb5nvZplXru8KVEt_e9bQOy54EWudAemEVAyX-4P6FdWWazU5vMaRnDg53Y8gy4rt6LCwXK9WHNRbhkdAmTNNsfiuUd588lzMJVSVPeqzbl70yW8IH7hbwfscLjSf2-gP9evwDKoLMKjqyBtbsqGx-qtBE47y_-LKyP3TOJug0SBtiHIMb3xzN2rJaykAMRRz4kMaMF4_TZRV8kqZQqzF_xBoMJQD3nLYaN-G0qJYIse1JhVu4yHuH7vSvXdHA0x5dnBwwq4P3g35W5zv-cw4-b1cXnLq7TYUvHNVe6DcMAJogxW5ovA7wtEjF4yKxSbvlRaO065Jed5siLrIL17RahoHqew34kMzqXL0OUHvxId0A7myvLFy0YqhLnKg.1jfT6-IVamiQbS84hfX4lLtmoTiAmf0Ea0rpLPgYEek";
- // var handler = new JwtSecurityTokenHandler(); var claimsPrincipal =
- // handler.ValidateToken( jwt, new TokenValidationParameters { IssuerSigningKey
- // = signingKey, TokenDecryptionKey = encryptionKey, // ValidIssuer =
- // environment.IsEnvironment(Program.TestEnvironment) ? "http://localhost/" :
- // appSettings.Host, ValidateActor = false, ValidateAudience = false,
- // ValidateIssuer = false, ValidateIssuerSigningKey = false, ValidateLifetime =
- // false, ValidateTokenReplay = false, }, out var validatedToken );
- // Console.WriteLine(validatedToken.ToString()); ``` which as of this writing
- // outputs ```
- // {"alg":"A256KW","enc":"A256CBC-HS512","typ":"at+jwt"}.{"sub":"075561fa-98c0-40db-ad3d-9dc8abf240fd","name":"sw@ise.de","http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress":"sw@ise.de","oi_prst":"metabase","iss":"https://localhost:4041/","oi_au_id":"5dc00347-30fa-4ddb-91c1-471c505e7842","client_id":"metabase","oi_tkn_id":"e7430a26-4e13-4c21-8e6d-0f07dca66ff6","aud":"metabase","scope":"openid
- // email profile roles api:read api:write
- // offline_access","exp":1615405869,"iat":1615402269} ```
+ // you can execute
+ // ```
+ // var jwt = "eyJhbGciOiJBMjU2S1ciLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIiwidHlwIjoiYXQrand0In0.tn0jqCR5-01c_SDRwex6sPBNl1Vl1KSRA5Zs2UoXri9F4tG4v9B6qbLmxzsfkd0rLe55BozRV8VCChsZAt_mCZfoVGOYewwP.ogGZQ5Q2p7Yue7D6zPJlSQ.uNX1Qs9R30vZxAPj-LuJSwWnisoKHQ5qNC_K9WvA0JDSCS_orw6TsboSVCe5b_aUg3JvrkJX_Ir0c-bFMf0HVhOVNy1UJwxo9zegJOsm7MybPzK58H4ubt_PRnrSueLgnQX8aDcjbM38Imy3RN6a3r3aKawdWGcyA23sIH8XnVmGk0lDB_PqFrFE7x2MmG4fyVJINoki441UI-7x0sLFUi4o98Z-2vTFuCd9cLRY5LAeb0ZIuWwOI7dv2Q54w7uV765kHS3VIPtupzSSXgQmfPBJOzDeV_-sCZGwUuC0jL8x1vJw573fejPwpPmXj3EKgzXgbGfAHmjoIzkNvcfr--Dy7O8WxLjSERoBgW45Tq0xcCLL9Vx4JGWehOw1jY-KaKIzfjUW9CTSwLgWdhqonetAULZFRJAYOEJ8PtP49jXlvkdjmavRXaX2UD8FD5TUx30TkGQ_xyuo6HvokURQqIYILZiL6R_kVqh7kwUnj4dgBqni-56M1GYFuX2UzynagL6c6t4OlO7RhQJpgz1pzmGNdEb5nvZplXru8KVEt_e9bQOy54EWudAemEVAyX-4P6FdWWazU5vMaRnDg53Y8gy4rt6LCwXK9WHNRbhkdAmTNNsfiuUd588lzMJVSVPeqzbl70yW8IH7hbwfscLjSf2-gP9evwDKoLMKjqyBtbsqGx-qtBE47y_-LKyP3TOJug0SBtiHIMb3xzN2rJaykAMRRz4kMaMF4_TZRV8kqZQqzF_xBoMJQD3nLYaN-G0qJYIse1JhVu4yHuH7vSvXdHA0x5dnBwwq4P3g35W5zv-cw4-b1cXnLq7TYUvHNVe6DcMAJogxW5ovA7wtEjF4yKxSbvlRaO065Jed5siLrIL17RahoHqew34kMzqXL0OUHvxId0A7myvLFy0YqhLnKg.1jfT6-IVamiQbS84hfX4lLtmoTiAmf0Ea0rpLPgYEek";
+ // var handler = new JwtSecurityTokenHandler();
+ // var claimsPrincipal = handler.ValidateToken( jwt, new TokenValidationParameters { IssuerSigningKey = signingKey, TokenDecryptionKey = encryptionKey, ValidIssuer = environment.IsEnvironment(Program.TestEnvironment) ? "http://localhost/" : appSettings.Host, ValidateActor = false, ValidateAudience = false, ValidateIssuer = false, ValidateIssuerSigningKey = false, ValidateLifetime = false, ValidateTokenReplay = false, }, out var validatedToken );
+ // Console.WriteLine(validatedToken.ToString());
+ // ``` which as of this writing
+ // outputs
+ // ```
+ // {"alg":"A256KW","enc":"A256CBC-HS512","typ":"at+jwt"}.{"sub":"075561fa-98c0-40db-ad3d-9dc8abf240fd","name":"sw@ise.de","http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress":"sw@ise.de","oi_prst":"metabase","iss":"https://localhost:4041/","oi_au_id":"5dc00347-30fa-4ddb-91c1-471c505e7842","client_id":"metabase","oi_tkn_id":"e7430a26-4e13-4c21-8e6d-0f07dca66ff6","aud":"metabase","scope":"openid email profile roles api:read api:write offline_access","exp":1615405869,"iat":1615402269}
+ // ```
}
)
// Register the OpenIddict validation components.
.AddValidation(_ =>
{
+ // The validation handler uses OpenID Connect discovery to
+ // retrieve the issuer signing keys used to validate tokens.
_.SetIssuer(appSettings.Uri);
// Configure the audience accepted by this resource server.
_.AddAudiences(OpenIdConnectConstants.Client.MetabaseClientId);
@@ -524,6 +528,7 @@ AppSettings appSettings
.EnableStatusCodePagesIntegration() // https://documentation.openiddict.com/integrations/aspnet-core#status-code-pages-middleware-integration
.EnableRedirectionEndpointPassthrough() // https://documentation.openiddict.com/integrations/aspnet-core#pass-through-mode
.EnablePostLogoutRedirectionEndpointPassthrough();
+
// .DisableTransportSecurityRequirement(); // https://documentation.openiddict.com/integrations/aspnet-core#transport-security-requirement
// Register the System.Net.Http integration and use the identity of the current
diff --git a/backend/src/Configuration/GraphQlConfiguration.cs b/backend/src/Configuration/GraphQlConfiguration.cs
index e9962bcc3..3f8b748e9 100644
--- a/backend/src/Configuration/GraphQlConfiguration.cs
+++ b/backend/src/Configuration/GraphQlConfiguration.cs
@@ -1,4 +1,6 @@
using System;
+using System.Text.RegularExpressions;
+using HotChocolate.AspNetCore;
using HotChocolate.Configuration;
using HotChocolate.Data;
using HotChocolate.Data.Filters;
@@ -8,18 +10,21 @@
using HotChocolate.Language;
using HotChocolate.Types;
using HotChocolate.Types.Descriptors;
-using HotChocolate.Types.Descriptors.Definitions;
+using HotChocolate.Types.Descriptors.Configurations;
using HotChocolate.Types.NodaTime;
using Metabase.Authentication;
using Metabase.Data;
using Metabase.GraphQl;
using Metabase.GraphQl.DataX;
using Metabase.GraphQl.Filters;
+using Metabase.GraphQl.Scalars;
using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NodaTime;
+using DurationType = HotChocolate.Types.NodaTime.DurationType;
namespace Metabase.Configuration;
@@ -32,8 +37,7 @@ IWebHostEnvironment environment
{
// Automatic-Persisted-Queries Services
services
- .AddMemoryCache()
- .AddSha256DocumentHashProvider(HashFormat.Hex); // https://chillicream.com/docs/hotchocolate/v15/security/#fips-compliance
+ .AddMemoryCache();
// GraphQL Server
var serverBuilder = services
.AddGraphQLServer();
@@ -43,59 +47,95 @@ IWebHostEnvironment environment
serverBuilder.TryAddTypeInterceptor();
}
serverBuilder
- // TODO add warmup task once we upgrade to version 16: https://chillicream.com/docs/hotchocolate/v16/server/warmup
- // .AddWarmupTask(async (executor, cancellationToken) =>
- // {
- // await executor.ExecuteAsync("{ __typename }", cancellationToken);
- // })
+ .AddSha256DocumentHashProvider(HashFormat.Hex) // https://chillicream.com/docs/hotchocolate/v15/security/#fips-compliance
+ .AddApplicationService() // for `AddHttpRequestInterceptor`
+ .AddApplicationService>() // for `AddDiagnosticEventListener`
.DisableIntrospection(false) // if the introspection result becomes too big we need to disable it in production
- .BindRuntimeType()
- // Services https://chillicream.com/docs/hotchocolate/v13/integrations/entity-framework#registerdbcontext
.RegisterDbContextFactory()
+ // .AddInstrumentation()
.AddMutationConventions(new MutationConventionOptions { ApplyToAllMutations = false })
// Extensions
- .AddProjections()
+ .AddNodaTime()
+ .BindRuntimeType()
+ .AddTypeConverter(_ => _.ToTimeSpan())
+ .AddTypeConverter(_ => Duration.FromTimeSpan(_))
+ // .AddTypeConverter(
+ // _ => _.ToDateTimeOffset()
+ // )
+ // .AddTypeConverter(
+ // _ => OffsetDateTime.FromDateTimeOffset(_)
+ // )
+ // .AddProjections()
.AddFiltering()
.AddSorting()
.AddConvention()
.AddQueryContext()
.AddAuthorization()
- .AddGlobalObjectIdentification()
.AddQueryFieldToMutationPayloads()
- .ModifyOptions(options =>
+ .AddGlobalObjectIdentification(_ =>
+ {
+ // _.MaxAllowedNodeBatchSize = 100;
+ _.EnsureAllNodesCanBeResolved = true;
+ }
+ )
+ .ModifyOptions(_ =>
{
// https://github.com/ChilliCream/hotchocolate/blob/main/src/HotChocolate/Core/src/Types/Configuration/Contracts/ISchemaOptions.cs
- options.StrictValidation = true;
- options.UseXmlDocumentation = false;
- options.SortFieldsByName = true;
- options.RemoveUnreachableTypes = false;
- options.RemoveUnusedTypeSystemDirectives = true;
- options.DefaultBindingBehavior = BindingBehavior.Implicit;
+ _.StrictValidation = true;
+ _.UseXmlDocumentation = false;
+ _.SortFieldsByName = true;
+ _.RemoveUnreachableTypes = false;
+ _.RemoveUnusedTypeSystemDirectives = true;
+ _.DefaultBindingBehavior = BindingBehavior.Implicit;
// options.DefaultFieldBindingFlags = FieldBindingFlags.InstanceAndStatic;
- options.EnableDirectiveIntrospection = true;
- options.DefaultDirectiveVisibility = DirectiveVisibility.Public;
- options.DefaultResolverStrategy = ExecutionStrategy.Parallel;
- options.ValidatePipelineOrder = true;
- options.StrictRuntimeTypeValidation = true;
- options.EnableOneOf = true;
- options.EnsureAllNodesCanBeResolved = true;
- options.EnableFlagEnums = false;
- options.EnableDefer = false;
- options.EnableStream = false;
- options.EnableSemanticNonNull = false;
- options.StripLeadingIFromInterface = false;
- options.EnableTag = true;
- options.PublishRootFieldPagesToPromiseCache = true;
+ _.EnableDirectiveIntrospection = true;
+ _.EnableOptInFeatures = true;
+ _.DefaultDirectiveVisibility = DirectiveVisibility.Public;
+ _.DefaultResolverStrategy = ExecutionStrategy.Parallel;
+ _.ValidatePipelineOrder = true;
+ _.StrictRuntimeTypeValidation = true;
+ _.EnableFlagEnums = false;
+ _.EnableDefer = false;
+ _.EnableStream = false;
+ _.StripLeadingIFromInterface = false;
+ _.EnableTag = true;
+ _.PublishRootFieldPagesToPromiseCache = true;
+ // options.OperationDocumentCacheSize = 200;
+ // options.PreparedOperationCacheSize = 100;
}
)
- .ModifyRequestOptions(options =>
+ .ModifyServerOptions(_ =>
+ {
+ _.AllowedGetOperations = AllowedGetOperations.Query;
+ _.Batching = AllowedBatching.All;
+ _.EnableGetRequests = false;
+ _.EnableMultipartRequests = true;
+ _.EnableSchemaRequests = true;
+ // Nitro
+ _.Tool.DisableTelemetry = true;
+ _.Tool.Enable = true; // environment.IsDevelopment()
+ _.Tool.GraphQLEndpoint = GraphQlConstants.EndpointPath;
+ _.Tool.IncludeCookies = false;
+ _.Tool.Title = "GraphQL";
+ _.Tool.UseBrowserUrlAsGraphQLEndpoint = false;
+ _.Tool.UseGet = false;
+ }
+ )
+ .ModifyRequestOptions(_ =>
{
// https://github.com/ChilliCream/hotchocolate/blob/main/src/HotChocolate/Core/src/Execution/Options/RequestExecutorOptions.cs
- options.ExecutionTimeout = TimeSpan.FromSeconds(120);
- options.IncludeExceptionDetails = !environment.IsProduction(); // Default is `Debugger.IsAttached`.
- /* options.QueryCacheSize = ...; */
- /* options.UseComplexityMultipliers = ...; */
- options.EnableSchemaFileSupport = true;
+ _.ExecutionTimeout = TimeSpan.FromSeconds(120);
+ _.IncludeExceptionDetails = !environment.IsProduction(); // Default is `Debugger.IsAttached`.
+ _.AllowErrorHandlingModeOverride = true;
+ // options.QueryCacheSize = ...;
+ // options.UseComplexityMultipliers = ...;
+ // options.EnableSchemaFileSupport = true;
+ }
+ )
+ .ModifyCostOptions(_ =>
+ {
+ _.MaxFieldCost = 40000;
+ _.MaxTypeCost = 40000;
}
)
// Configure
@@ -108,11 +148,6 @@ IWebHostEnvironment environment
// Persisted queries
/* .AddFileSystemOperationDocumentStorage("./persisted_operations") */
/* .UsePersistedOperationPipeline(); */
- // HotChocolate uses the default authentication scheme,
- // which we set to `null` in `AuthConfiguration` to force
- // users to be explicit about what scheme to use when
- // making it easier to grasp the various authentication
- // flows.
.AddHttpRequestInterceptor(async (httpContext, requestExecutor, requestBuilder, cancellationToken) =>
{
await httpContext.RequestServices
@@ -125,26 +160,15 @@ await httpContext.RequestServices
)
)
// Scalar Types
- // TODO Use `MyUuidType` and `MyUrlType` (see code below)
+ // TODO Add `MyUuidType` based on https://github.com/ChilliCream/graphql-platform/blob/main/src/HotChocolate/Core/src/Types/Types/Scalars/UuidType.cs
.AddType(new UuidType("Uuid", defaultFormat: 'D')) // https://chillicream.com/docs/hotchocolate/defining-a-schema/scalars#uuid-type
- .AddType(new UrlType("Url"))
- .AddType(new JsonType("Any", BindingBehavior.Implicit)) // https://chillicream.com/blog/2023/02/08/new-in-hot-chocolate-13#json-scalar
+ .AddType(new MyUriType())
+ .AddType(new AnyType("Any"))
+ .AddType()
+ .AddType()
+ .AddType()
.AddType()
- .AddType()
- .AddType()
- // .AddType()
- // Register converters between NodaTime's `OffsetDateTime` and .NET's
- // `DateTimeOffset` to reuse the existing `DateTimeType`
- // https://chillicream.com/docs/hotchocolate/v15/defining-a-schema/scalars#custom-converters
- .BindRuntimeType()
- .AddTypeConverter(
- _ => _.ToDateTimeOffset()
- )
- .AddTypeConverter(
- _ => OffsetDateTime.FromDateTimeOffset(_)
- )
- // Object Types
- .AddType()
+ .BindRuntimeType()
// Query, Mutation, Subscription, Object, and Input Types
.AddQueryType(_ => _.Name(nameof(Query)))
.AddMutationType(_ => _.Name(nameof(Mutation)))
@@ -153,10 +177,11 @@ await httpContext.RequestServices
.AddTypes()
// Paging
.AddDbContextCursorPagingProvider()
+ // .AddCursorKeySerializer(new OffsetDateTimeCursorKeySerializer())
.ModifyPagingOptions(_ =>
{
- _.MaxPageSize = int.MaxValue - 1;
- _.DefaultPageSize = 100;
+ _.MaxPageSize = (int)GraphQlConstants.MaximumPageSize;
+ _.DefaultPageSize = (int)GraphQlConstants.MaximumPageSize;
_.IncludeTotalCount = true;
_.IncludeNodesField = false;
_.InferConnectionNameFromField = true;
@@ -167,6 +192,7 @@ await httpContext.RequestServices
.AddInMemoryOperationDocumentStorage(); // Needed by the automatic persisted operation pipeline
}
+ //
// private sealed class MyUuidType : UuidType
// {
// private const string SpecifiedByString = "https://tools.ietf.org/html/rfc4122";
@@ -185,19 +211,6 @@ await httpContext.RequestServices
// }
// }
- // private sealed class MyUrlType : UrlType
- // {
- // private const string SpecifiedByString = "https://tools.ietf.org/html/rfc3986";
- //
- // public MyUrlType(
- // string name,
- // string? description = null,
- // BindingBehavior bind = BindingBehavior.Explicit)
- // : base(name, description, bind)
- // {
- // SpecifiedBy = new Uri(SpecifiedByString, UriKind.Absolute);
- // }
- // }
}
// https://github.com/ChilliCream/graphql-platform/blob/main/src/HotChocolate/Core/src/Types/Configuration/TypeInterceptor.cs
@@ -209,25 +222,41 @@ public override void OnBeforeInitialize(ITypeDiscoveryContext discoveryContext)
Console.WriteLine($"[INIT] Discovered type '{discoveryContext.Type.GetType().Name}'");
}
- public override void OnBeforeCompleteName(ITypeCompletionContext completionContext, DefinitionBase definition)
+ public override void OnBeforeCompleteName(ITypeCompletionContext completionContext, TypeSystemConfiguration configuration)
{
- Console.WriteLine($"[NAME] Finalizing name '{definition.Name}' for type '{completionContext.Type.GetType().Name}'");
+ Console.WriteLine($"[NAME] Finalizing name '{configuration.Name}' for type '{completionContext.Type.GetType().Name}'");
}
- public override void OnAfterCompleteType(ITypeCompletionContext completionContext, DefinitionBase definition)
+ public override void OnAfterCompleteType(ITypeCompletionContext completionContext, TypeSystemConfiguration configuration)
{
- Console.WriteLine($"[DONE] Completed type '{completionContext.Type.GetType().Name}' with name '{definition.Name}'");
+ Console.WriteLine($"[DONE] Completed type '{completionContext.Type.GetType().Name}' with name '{configuration.Name}'");
}
}
-public sealed class CustomNamingConventions
+public sealed partial class CustomNamingConventions
: DefaultNamingConventions
{
+ public static string CorrectPlural(string name) => name
+ .Replace("Assemblys", "Assemblies")
+ .Replace($"{nameof(FileMetaInformation)}s", "FilesMetaInformation");
+
+ // `(?!...)` is a negative lookahead. It is a zero-width assertion, meaning
+ // that it does not move the regex engine's cursor (nor consume text).
+ [GeneratedRegex(@$"^({nameof(FilterInputType)}Of|{nameof(Nullable)}Of(?!{nameof(DataKind)}))")]
+ private static partial Regex PrefixesToRemoveRegex();
+
+ public override string GetTypeName(Type type) =>
+ PrefixesToRemoveRegex().Replace(
+ base.GetTypeName(type),
+ string.Empty
+ );
}
// See https://chillicream.com/docs/hotchocolate/fetching-data/filtering/#filter-conventions
public partial class CustomFilterConvention : FilterConvention
{
+ [GeneratedRegex(@$"({nameof(FilterInputType)}|{GraphQlConstants.FilterInputSuffix}|FilterType|FilterInput)$")]
+ private static partial Regex FilterInputSuffixesToReplaceRegex();
private const string InputPostFix = "FilterInput";
private const string InputTypePostFix = "FilterInputType";
@@ -254,66 +283,55 @@ protected override void Configure(IFilterConventionDescriptor descriptor)
descriptor.Provider(
new QueryableFilterProvider(_ => _
.AddDefaultFieldHandlers()
- .AddFieldHandler>()
+ .AddFieldHandler>(context =>
+ new QueryableComparableInClosedIntervalHandler(context.TypeConverter, context.InputParser)
+ )
)
);
}
- // For the base implementation see https://github.com/ChilliCream/hotchocolate/blob/f0dff93a14cb7ddecc7b3a0530a687a5bc4bad71/src/HotChocolate/Data/src/Data/Filters/Convention/FilterConvention.cs#L129
- public override string GetTypeName(Type runtimeType)
- {
- // return base.GetTypeName(runtimeType);
- return GetTypeName(runtimeType, plural: false);
- }
+ // For the base implementation see https://github.com/ChilliCream/graphql-platform/blob/develop/src/HotChocolate/Data/src/Data/Filters/Convention/FilterConvention.cs#L129
+ public override string GetTypeName(Type runtimeType) =>
+ CustomNamingConventions.CorrectPlural(
+ GetTypeName(runtimeType, plural: false)
+ );
- public string GetTypeName(Type runtimeType, bool plural)
+ private string GetTypeName(Type runtimeType, bool plural)
{
ArgumentNullException.ThrowIfNull(runtimeType);
var pluralSuffix = plural ? "s" : "";
if (typeof(IEnumOperationFilterInputType).IsAssignableFrom(runtimeType)
&& runtimeType.GenericTypeArguments.Length == 1
- && runtimeType.GetGenericTypeDefinition() == typeof(EnumOperationFilterInputType<>))
+ && runtimeType.GetGenericTypeDefinition() == typeof(EnumOperationFilterInputType<>)
+ )
{
var genericName = _namingConventions.GetTypeName(runtimeType.GenericTypeArguments[0]);
- return $"{genericName}{pluralSuffix}{GraphQlConstants.FilterInputSuffix}"; ;
- // return genericName + "OperationFilterInput";
+ return $"{genericName}{pluralSuffix}{GraphQlConstants.FilterInputSuffix}";
}
if (typeof(IComparableOperationFilterInputType).IsAssignableFrom(runtimeType)
&& runtimeType.GenericTypeArguments.Length == 1
&& runtimeType.GetGenericTypeDefinition()
- == typeof(ComparableOperationFilterInputType<>))
+ == typeof(ComparableOperationFilterInputType<>)
+ )
{
var genericName = _namingConventions.GetTypeName(runtimeType.GenericTypeArguments[0]);
return $"Comparable{genericName}{pluralSuffix}{GraphQlConstants.FilterInputSuffix}";
- // return $"Comparable{genericName}OperationFilterInput";
}
if (typeof(IListFilterInputType).IsAssignableFrom(runtimeType)
- && runtimeType.GenericTypeArguments.Length == 1)
+ && runtimeType.GenericTypeArguments.Length == 1
+ )
{
var genericType = runtimeType.GenericTypeArguments[0];
- var genericName = typeof(FilterInputType).IsAssignableFrom(genericType)
+ return typeof(FilterInputType).IsAssignableFrom(genericType)
? GetTypeName(genericType, plural: true)
- : "List" + _namingConventions.GetTypeName(genericType);
- return $"{genericName}";
- // return "List" + genericName;
+ : $"{_namingConventions.GetTypeName(genericType)}{pluralSuffix}";
}
var name = _namingConventions.GetTypeName(runtimeType);
- var isInputObjectType = typeof(FilterInputType).IsAssignableFrom(runtimeType);
- var isEndingInput = name.EndsWith(InputPostFix, StringComparison.Ordinal);
- var isEndingInputType = name.EndsWith(InputTypePostFix, StringComparison.Ordinal);
- if (isInputObjectType && isEndingInputType)
- {
- return $"{name[..^"FilterInputType".Length]}{pluralSuffix}{GraphQlConstants.FilterInputSuffix}";
- }
- if (isInputObjectType && !isEndingInput && !isEndingInputType)
+ if (typeof(FilterInputType).IsAssignableFrom(runtimeType) || !name.EndsWith("FilterInput", StringComparison.InvariantCulture))
{
- return $"{name}{pluralSuffix}{GraphQlConstants.FilterInputSuffix}";
+ return $"{FilterInputSuffixesToReplaceRegex().Replace(name, string.Empty)}{pluralSuffix}{GraphQlConstants.FilterInputSuffix}";
}
- if (!isInputObjectType && !isEndingInput)
- {
- return $"{name}{pluralSuffix}{GraphQlConstants.FilterInputSuffix}";
- }
- return name;
+ return $"{name}{pluralSuffix}";
}
}
@@ -384,6 +402,8 @@ this IFilterConventionDescriptor descriptor
.BindRuntimeType()
.BindRuntimeType()
.BindRuntimeType()
+ .BindRuntimeType()
+ .BindRuntimeType()
.BindRuntimeType()
.BindRuntimeType()
.BindRuntimeType()
@@ -396,16 +416,24 @@ this IFilterConventionDescriptor descriptor
.BindRuntimeType()
.BindRuntimeType()
.BindRuntimeType()
+ .BindRuntimeType()
+ .BindRuntimeType()
.BindRuntimeType()
.BindRuntimeType()
- // .BindRuntimeType()
- // .BindRuntimeType()
- // .BindRuntimeType()
- // .BindRuntimeType()
- .BindRuntimeType()
- .BindRuntimeType()
- .BindRuntimeType()
- .BindRuntimeType();
+ .BindRuntimeType()
+ .BindRuntimeType()
+ .BindRuntimeType()
+ .BindRuntimeType()
+ .BindRuntimeType()
+ .BindRuntimeType()
+ .BindRuntimeType()
+ .BindRuntimeType()
+ .BindRuntimeType()
+ .BindRuntimeType()
+ .BindRuntimeType()
+ .BindRuntimeType()
+ .BindRuntimeType()
+ .BindRuntimeType();
}
}
@@ -416,4 +444,10 @@ protected override void Configure(ISortConventionDescriptor descriptor)
{
descriptor.AddDefaults();
}
+
+ // For the base implementation see https://github.com/ChilliCream/graphql-platform/blob/develop/src/HotChocolate/Data/src/Data/Sorting/Convention/SortConvention.cs#L134C5
+ public override string GetTypeName(Type runtimeType) =>
+ CustomNamingConventions.CorrectPlural(
+ base.GetTypeName(runtimeType)
+ );
}
\ No newline at end of file
diff --git a/backend/src/Controllers/AuthenticationController.cs b/backend/src/Controllers/AuthenticationController.cs
index f61d69453..22a5ab3e8 100644
--- a/backend/src/Controllers/AuthenticationController.cs
+++ b/backend/src/Controllers/AuthenticationController.cs
@@ -27,7 +27,7 @@ AppSettings appSettings
private readonly Uri _issuer = appSettings.Uri;
[HttpGet("~/connect/client/login")]
- public ActionResult LogIn(string? returnUrl)
+ public ActionResult LogIn(string? returnTo)
{
// Ask the OpenIddict client middleware to redirect the user agent to the identity provider.
return Challenge(
@@ -41,7 +41,7 @@ public ActionResult LogIn(string? returnUrl)
)
{
// Only allow local return URLs to prevent open redirect attacks.
- RedirectUri = SanitizeReturnUrl(returnUrl)
+ RedirectUri = SanitizeReturnUrl(returnTo)
},
OpenIddictClientAspNetCoreDefaults.AuthenticationScheme
);
@@ -50,7 +50,7 @@ public ActionResult LogIn(string? returnUrl)
[HttpPost("~/connect/client/logout")]
[Authorize(AuthenticationSchemes = AuthenticationConstants.IdentityAndCookieAndBearerTokenAuthenticationScheme)]
[RequireAntiforgeryToken]
- public async Task LogOut(string? returnUrl)
+ public async Task LogOut(string? returnTo)
{
// Retrieve the identity stored in the local authentication cookie. If it's not available,
// this indicate that the user is already logged out locally (or has not logged in yet).
@@ -59,7 +59,7 @@ public async Task LogOut(string? returnUrl)
{
// Only allow local return URLs to prevent open redirect attacks.
// https://learn.microsoft.com/en-us/aspnet/core/security/preventing-open-redirects
- return LocalRedirect(SanitizeReturnUrl(returnUrl));
+ return LocalRedirect(SanitizeReturnUrl(returnTo));
}
// Remove the local authentication cookie before triggering a redirection to the remote server.
@@ -79,7 +79,7 @@ public async Task LogOut(string? returnUrl)
)
{
// Only allow local return URLs to prevent open redirect attacks.
- RedirectUri = SanitizeReturnUrl(returnUrl)
+ RedirectUri = SanitizeReturnUrl(returnTo)
},
OpenIddictClientAspNetCoreDefaults.AuthenticationScheme
);
@@ -198,7 +198,7 @@ public async Task LogInCallback(string provider)
provider,
AuthenticationTokens.From(accessToken, result)
);
- if (errors.Count >= 1)
+ if (errors.Count > 0)
{
throw new InvalidOperationException(
string.Join(
@@ -239,11 +239,11 @@ public async Task LogOutCallback(string provider)
);
}
- private string SanitizeReturnUrl(string? returnUrl)
+ private string SanitizeReturnUrl(string? returnTo)
{
return
- returnUrl is not null && Url.IsLocalUrl(returnUrl)
- ? returnUrl
+ returnTo is not null && Url.IsLocalUrl(returnTo)
+ ? returnTo
: "/";
}
-}
\ No newline at end of file
+}
diff --git a/backend/src/Controllers/AuthorizationController.cs b/backend/src/Controllers/AuthorizationController.cs
index dded00788..e57f95f63 100644
--- a/backend/src/Controllers/AuthorizationController.cs
+++ b/backend/src/Controllers/AuthorizationController.cs
@@ -12,9 +12,10 @@
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Metabase.Authentication;
-using Metabase.Authorization;
using Metabase.Data;
using Metabase.Data.OpenIdConnect;
+using Metabase.Extensions;
+using Metabase.GraphQl.OpenIdConnect;
using Metabase.ViewModels.Authorization;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Antiforgery;
@@ -30,6 +31,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
+using NodaTime;
using OpenIddict.Abstractions;
using OpenIddict.Core;
using OpenIddict.Server.AspNetCore;
@@ -38,9 +40,10 @@
namespace Metabase.Controllers;
public sealed class AuthorizationController(
+ IClock clock,
OpenIddictApplicationManager applicationManager,
OpenIddictAuthorizationManager authorizationManager,
- OpenIddictScopeManager scopeManager,
+ OpenIddictScopeManager scopeManager,
SignInManager signInManager,
UserManager userManager,
ApplicationDbContext dbContext,
@@ -89,7 +92,7 @@ private async Task CreateClaimsIdentityAsync(
// `user.Name` instead of the default value `user.UserName` for the
// claim `Claims.Name` because `user.UserName` is actually the email
// address.
- identity.SetClaim(Claims.Subject, await userManager.GetUserIdAsync(user));
+ identity.SetClaim(Claims.Subject, IOpenIdConnectSubject.BuildUserSubject(user.Id));
identity.SetClaim(Claims.Name, user.Name);
// identity.SetClaim(Claims.Email, await userManager.GetEmailAsync(user));
// identity.SetClaims(Claims.Role, [.. await userManager.GetRolesAsync(user)]);
@@ -135,7 +138,7 @@ string applicationId
var authorization = authorizations.LastOrDefault();
authorization ??= await authorizationManager.CreateAsync(
identity,
- subject: await userManager.GetUserIdAsync(user),
+ subject: IOpenIdConnectSubject.BuildUserSubject(user.Id),
client: applicationId,
type: AuthorizationTypes.Permanent,
scopes: identity.GetScopes()
@@ -190,7 +193,7 @@ public async Task Authorize()
|| (
request.MaxAge is not null
&& result.Properties?.IssuedUtc is not null
- && TimeProvider.System.GetUtcNow() - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value)
+ && clock.GetUtcNow().ToDateTimeOffset() - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value)
)
)
&& TempData[IgnoreAuthenticationChallengeKey] is null or false
@@ -315,11 +318,12 @@ await CreatePermanentAuthorization(
// In every other case, render the consent form.
default:
- return View(new AuthorizeViewModel
- {
- ApplicationName = await applicationManager.GetLocalizedDisplayNameAsync(application),
- Scope = request.Scope
- });
+ return View(new AuthorizeViewModel(
+ await applicationManager.GetLocalizedDisplayNameAsync(application) ?? application.ClientId ?? "Unknown",
+ request.GetScopes()
+ .Select(_ => _.ToOpenIdConnectScope())
+ .ToArray()
+ ));
}
}
@@ -405,7 +409,12 @@ public async Task Deny()
// Remove the `AuthenticationConstants.IdentityApplicationScheme`
// cookie as it was only needed to authenticate the user.
await signInManager.SignOutAsync();
- return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
+ var properties = new AuthenticationProperties(new Dictionary
+ {
+ [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.AccessDenied,
+ [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user denied the authorization request."
+ });
+ return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
#endregion
@@ -650,9 +659,10 @@ public async Task Exchange()
roleType: Claims.Role);
// Add the claims that will be persisted in the tokens (use the client_id as the subject identifier).
- var clientId = await applicationManager.GetClientIdAsync(application);
+ var clientId = await applicationManager.GetClientIdAsync(application)
+ ?? throw new InvalidOperationException("The application does not have a client ID.");
var displayName = await applicationManager.GetDisplayNameAsync(application);
- identity.SetClaim(Claims.Subject, $"{CommonAuthorization.ClientSubjectPrefix}{clientId}");
+ identity.SetClaim(Claims.Subject, IOpenIdConnectSubject.BuildClientSubject(clientId));
identity.SetClaim(Claims.Name, displayName);
// Note: In the original OAuth 2.0 specification, the client credentials grant
diff --git a/backend/src/Controllers/ErrorController.cs b/backend/src/Controllers/ErrorController.cs
new file mode 100644
index 000000000..8e19c32a9
--- /dev/null
+++ b/backend/src/Controllers/ErrorController.cs
@@ -0,0 +1,24 @@
+using Metabase.ViewModels.Common;
+using Microsoft.AspNetCore;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Metabase.Controllers;
+
+public class ErrorController : Controller
+{
+ [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
+ [Route("~/error")]
+ public IActionResult Error()
+ {
+ var response = HttpContext.GetOpenIddictServerResponse() ?? HttpContext.GetOpenIddictClientResponse();
+ if (response is not null)
+ {
+ return View(new ErrorViewModel
+ {
+ Error = response.Error,
+ ErrorDescription = response.ErrorDescription
+ });
+ }
+ return View(new ErrorViewModel());
+ }
+}
\ No newline at end of file
diff --git a/backend/src/Controllers/GetHttpsResourceController.cs b/backend/src/Controllers/GetHttpsResourceController.cs
new file mode 100644
index 000000000..b885d427a
--- /dev/null
+++ b/backend/src/Controllers/GetHttpsResourceController.cs
@@ -0,0 +1,139 @@
+using System;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Mime;
+using System.Threading;
+using System.Threading.Tasks;
+using Metabase.Authentication;
+using Metabase.Data;
+using Metabase.GraphQl.DataX;
+using Metabase.GraphQl.Requests;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace Metabase.Controllers;
+
+// Inspired by https://docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads?view=aspnetcore-5.0#upload-large-files-with-streaming
+// and https://github.com/dotnet/AspNetCore.Docs/blob/b4599432690b8753fc2eac23d52957f47e01997a/aspnetcore/mvc/models/file-uploads/samples/3.x/SampleApp/
+[ApiController]
+public sealed class GetHttpsResourcesController : Controller
+{
+ private bool _disposed;
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (!_disposed)
+ {
+ // Dispose of resources held by this instance.
+ _disposed = true;
+ }
+ }
+
+ // Disposable types implement a finalizer.
+ ~GetHttpsResourcesController()
+ {
+ Dispose(false);
+ }
+
+ private const string GetByVertexIdRouteName = "GetResourceByVertexId";
+
+ [HttpGet("~/api/resources/{vertexId}", Name = GetByVertexIdRouteName)]
+ [Authorize(AuthenticationSchemes = AuthenticationConstants.IdentityAndCookieAndBearerTokenAuthenticationScheme)]
+ [AllowAnonymous]
+ [EndpointDescription("Get an HTTP resource in the media type of its data format from a database passing along an access token that identifies the logged-in user.")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task Get(
+ [FromRoute] string vertexId,
+ [FromQuery] Guid dataId,
+ [FromQuery] DataKind dataKind,
+ [FromQuery] Guid databaseId,
+ [FromServices] IDbContextFactory databaseContextFactory,
+ [FromServices] QueryingDatabases queryingDatabases,
+ [FromServices] DataQueries dataQueries,
+ CancellationToken cancellationToken
+ )
+ {
+ await using var databaseContext = await databaseContextFactory.CreateDbContextAsync(cancellationToken);
+ var database = await databaseContext.Databases.AsNoTracking()
+ .Where(_ => _.Id == databaseId)
+ .SingleOrDefaultAsync(cancellationToken);
+ if (database is null)
+ {
+ return Problem(
+ title: "Database Not Found",
+ detail: $"There is no database with ID '{databaseId:D}'.",
+ statusCode: StatusCodes.Status404NotFound,
+ instance: HttpContext.Request.Path
+ );
+ }
+ var data = await dataQueries.GetDataAsync(database, dataId, dataKind, null, null, cancellationToken);
+ if (data is null)
+ {
+ return Problem(
+ title: "Data Not Found",
+ detail: $"There is no data with ID '{dataId:D}' of kind '{dataKind}' in the database with ID '{databaseId:D}'.",
+ statusCode: StatusCodes.Status404NotFound,
+ instance: HttpContext.Request.Path
+ );
+ }
+ var getHttpsResource =
+ data.ResourceTree.Root.VertexId == vertexId
+ ? data.ResourceTree.Root.Value
+ : data.ResourceTree.NonRootVertices.FirstOrDefault(_ => _.VertexId == vertexId)?.Value;
+ if (getHttpsResource is null)
+ {
+ return Problem(
+ title: "Resource Not Found",
+ detail: $"There is no GET HTTPS resource with vertex ID '{vertexId}' in the data with ID '{dataId:D}' of kind '{dataKind}' in the database with ID '{databaseId:D}'.",
+ statusCode: StatusCodes.Status404NotFound,
+ instance: HttpContext.Request.Path
+ );
+ }
+ var dataFormat = await databaseContext.DataFormats.AsNoTracking()
+ .Where(_ => _.Id == getHttpsResource.DataFormatId)
+ .SingleOrDefaultAsync(cancellationToken);
+ if (dataFormat is null)
+ {
+ return Problem(
+ title: "Data Format Not Found",
+ detail: $"There is no data format with ID '{getHttpsResource.DataFormatId:D}'.",
+ statusCode: StatusCodes.Status404NotFound,
+ instance: HttpContext.Request.Path
+ );
+ }
+ // Do not use `using var ...` below to not dispose objects when the associated response stream is still in use
+ var httpClient = await queryingDatabases.CreateHttpClientAsync(database, cancellationToken);
+ var httpResponseMessage =
+ await httpClient.GetAsync(
+ getHttpsResource.Locator,
+ HttpCompletionOption.ResponseHeadersRead, // do not buffer in memory before returning
+ cancellationToken
+ );
+ if (httpResponseMessage.StatusCode is not HttpStatusCode.OK)
+ {
+ return Problem(
+ title: "Fetch Failure",
+ detail: $"Failed to fetch the GET HTTPS resource from '{getHttpsResource.Locator}'.",
+ statusCode: (int)httpResponseMessage.StatusCode,
+ instance: HttpContext.Request.Path
+ );
+ }
+ var contentType = httpResponseMessage.Content.Headers.ContentType;
+ var responseStream =
+ await httpResponseMessage.Content
+ .ReadAsStreamAsync(cancellationToken);
+ var fileExtension = dataFormat?.Extension is null ? "unknown" : dataFormat.Extension;
+ return File(
+ responseStream,
+ contentType?.ToString() ?? MediaTypeNames.Application.Octet,
+ $"{getHttpsResource.HashValue}.{fileExtension}",
+ enableRangeProcessing: true
+ );
+ }
+}
\ No newline at end of file
diff --git a/backend/src/Data/ApplicationDbContext.cs b/backend/src/Data/ApplicationDbContext.cs
index 8a4b749ba..b636e5ba8 100644
--- a/backend/src/Data/ApplicationDbContext.cs
+++ b/backend/src/Data/ApplicationDbContext.cs
@@ -3,10 +3,14 @@
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
-using SchemaNameOptionsExtension = Metabase.Data.Extensions.SchemaNameOptionsExtension;
using NodaTime;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Linq;
+using Metabase.Data.Extensions;
+using Metabase.Extensions;
+using Npgsql.EntityFrameworkCore.PostgreSQL.ValueGeneration;
namespace Metabase.Data;
@@ -14,11 +18,12 @@ namespace Metabase.Data;
// [Authentication and authorization for SPAs](https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity-api-authorization?view=aspnetcore-3.0)
// [Customize Identity Model](https://docs.microsoft.com/en-us/aspnet/core/security/authentication/customize-identity-model?view=aspnetcore-3.0)
public sealed class ApplicationDbContext
- : IdentityDbContext,
- IDataProtectionKeyContext
+: IdentityDbContext,
+ IDataProtectionKeyContext
{
private const string DefaultSchemaName = "metabase";
private readonly string _schemaName;
+ private readonly IClock _clock;
internal const string ComponentCategoryTypeName = "component_category";
internal const string DatabaseVerificationStateTypeName = "database_verification_state";
@@ -30,7 +35,8 @@ public sealed class ApplicationDbContext
internal const string StandardizerTypeName = "standardizer";
public ApplicationDbContext(
- DbContextOptions options
+ DbContextOptions options,
+ IClock clock
)
: base(options)
{
@@ -38,6 +44,7 @@ DbContextOptions options
// of `UseSchemaName` on a `DbContextOptionsBuilder` instance.
var schemaNameOptions = options.FindExtension();
_schemaName = schemaNameOptions is null ? DefaultSchemaName : schemaNameOptions.SchemaName;
+ _clock = clock;
}
// https://docs.microsoft.com/en-us/ef/core/miscellaneous/nullable-reference-types#dbcontext-and-dbset
@@ -114,22 +121,51 @@ public OffsetDateTimeUtcValueConverter()
}
}
- private static
- EntityTypeBuilder
- ConfigureEntity(
- EntityTypeBuilder builder
- )
- where TEntity : Entity
+ public override int SaveChanges()
+ {
+ UpdateTimestamps();
+ return base.SaveChanges();
+ }
+
+ public override Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
- // https://www.npgsql.org/efcore/modeling/generated-properties.html#guiduuid-generation
- builder
- .Property(e => e.Id)
- .HasDefaultValueSql("gen_random_uuid()");
- // https://www.npgsql.org/efcore/modeling/concurrency.html#the-postgresql-xmin-system-column
- builder
- .Property(e => e.Version)
- .IsRowVersion();
- return builder;
+ UpdateTimestamps();
+ return base.SaveChangesAsync(cancellationToken);
+ }
+
+ private void UpdateTimestamps()
+ {
+ var entries = ChangeTracker
+ .Entries()
+ .Where(_ =>
+ _.State == EntityState.Added
+ || _.State == EntityState.Modified
+ // || _.State == EntityState.Deleted
+ );
+ var now = _clock.GetUtcNow().ToDateTimeOffset();
+ foreach (var entry in entries)
+ {
+ switch (entry.State)
+ {
+ case EntityState.Added:
+ if (entry.Entity.CreatedAt == default)
+ {
+ entry.Entity.CreatedAt = now;
+ }
+ entry.Entity.UpdatedAt = now;
+ break;
+ case EntityState.Modified:
+ entry.Entity.UpdatedAt = now;
+ break;
+ // NOTE that soft deletes do not cascade
+ // case EntityState.Deleted:
+ // // soft delete
+ // entry.State = EntityState.Modified;
+ // entry.Entity.DeletedAt = now;
+ // entry.Entity.UpdatedAt = now;
+ // break;
+ }
+ }
}
private static void ConfigureIdentityEntities(
@@ -137,10 +173,7 @@ ModelBuilder builder
)
{
// https://stackoverflow.com/questions/19902756/asp-net-identity-dbcontext-confusion/35722688#35722688
- builder.Entity()
- .ToTable("user")
- .Property(e => e.Version)
- .IsRowVersion();
+ builder.Entity().ToTable("user");
builder.Entity().ToTable("role");
builder.Entity().ToTable("user_claim");
builder.Entity().ToTable("user_role");
@@ -381,48 +414,106 @@ private static void ConfigureMethodManager(ModelBuilder builder)
.OnDelete(DeleteBehavior.Restrict);
}
+ private static void ConfigureOpenIdConnect(ModelBuilder builder)
+ {
+ // auto-include for GraphQL `OpenIdConnectAuthorizationType`
+ builder.Entity()
+ .Navigation(a => a.Application)
+ .AutoInclude();
+ // auto-include for GraphQL `OpenIdConnectTokenType`
+ builder.Entity()
+ .Navigation(a => a.Application)
+ .AutoInclude();
+ builder.Entity()
+ .Navigation(a => a.Authorization)
+ .AutoInclude();
+ }
+
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.HasDefaultSchema(_schemaName);
builder.HasPostgresExtension("pgcrypto"); // https://www.npgsql.org/efcore/modeling/generated-properties.html#guiduuid-generation
+ builder.Entity().ToTable("component");
+ builder.Entity().ToTable("database");
+ builder.Entity().ToTable("gnu_pg_fingerprint");
+ builder.Entity().ToTable("data_format");
+ builder.Entity().ToTable("institution");
+ builder.Entity().ToTable("method");
ConfigureIdentityEntities(builder);
- ConfigureEntity(
- builder.Entity()
- )
- .ToTable("component");
ConfigureComponentAssembly(builder);
ConfigureComponentConcretizationAndGeneralization(builder);
ConfigureComponentManufacturer(builder);
ConfigureComponentVariant(builder);
- ConfigureEntity(
- builder.Entity()
- )
- .ToTable("database");
- ConfigureEntity(
- builder.Entity()
- )
- .ToTable("gnu_pg_fingerprint");
- ConfigureEntity(
- builder.Entity()
- )
- .ToTable("data_format");
- ConfigureEntity(
- builder.Entity()
- )
- .ToTable("institution");
ConfigureInstitutionMethodDeveloper(builder);
ConfigureInstitutionRepresentative(builder);
ConfigureOpenIdConnectApplicationOwner(builder);
ConfigureDatabaseOperator(builder);
- ConfigureEntity(
- builder.Entity()
- )
- .ToTable("method");
ConfigureUserMethodDeveloper(builder);
ConfigureInstitutionManager(builder);
ConfigureComponentManager(builder);
ConfigureDataFormatManager(builder);
ConfigureMethodManager(builder);
+ ConfigureOpenIdConnect(builder);
+ foreach (var entityType in builder.Model.GetEntityTypes())
+ {
+ if (typeof(IEntity).IsAssignableFrom(entityType.ClrType))
+ {
+ var entity = builder.Entity(entityType.ClrType);
+ entity.HasKey(nameof(IEntity.Id));
+ // https://www.npgsql.org/efcore/modeling/generated-properties.html#guiduuid-generation
+ entity
+ .Property(nameof(IEntity.Id))
+ .HasDefaultValueSql("uuidv7()")
+ .HasValueGenerator();
+ // https://www.npgsql.org/efcore/modeling/concurrency.html#the-postgresql-xmin-system-column
+ entity
+ .Property(nameof(IEntity.Version))
+ .IsRowVersion();
+ }
+ if (typeof(IAssociation).IsAssignableFrom(entityType.ClrType))
+ {
+ var association = builder.Entity(entityType.ClrType);
+ // https://www.npgsql.org/efcore/modeling/concurrency.html#the-postgresql-xmin-system-column
+ association
+ .Property(nameof(IAssociation.Version))
+ .IsRowVersion();
+ }
+ if (typeof(IAuditable).IsAssignableFrom(entityType.ClrType))
+ {
+ var auditable = builder.Entity(entityType.ClrType);
+ auditable
+ .Property(nameof(IAuditable.CreatedAt))
+ .HasDefaultValueSql("now()");
+ auditable
+ .Property(nameof(IAuditable.UpdatedAt))
+ .HasDefaultValueSql("now()");
+ // exclude soft-deleted entities with the effect that
+ // `context..ToList()` only returns rows where
+ // `DeletedAt` is null and
+ // `context..IgnoreQueryFilters().ToList()` returns
+ // all rows
+ // entity
+ // .HasQueryFilter((IAuditable _) => _.DeletedAt == null);
+ }
+ if (typeof(IEntity).IsAssignableFrom(entityType.ClrType)
+ && typeof(INamed).IsAssignableFrom(entityType.ClrType))
+ {
+ var entity = builder.Entity(entityType.ClrType);
+ // https://www.npgsql.org/efcore/modeling/generated-properties.html#guiduuid-generation
+ entity
+ .HasIndex(nameof(INamed.Name), nameof(IEntity.Id))
+ .IsUnique();
+ }
+ if (typeof(IEntity).IsAssignableFrom(entityType.ClrType)
+ && typeof(IAuditable).IsAssignableFrom(entityType.ClrType))
+ {
+ var entity = builder.Entity(entityType.ClrType);
+ // https://www.npgsql.org/efcore/modeling/generated-properties.html#guiduuid-generation
+ entity
+ .HasIndex(nameof(IAuditable.CreatedAt), nameof(IEntity.Id))
+ .IsUnique();
+ }
+ }
}
}
\ No newline at end of file
diff --git a/backend/src/Data/Association.cs b/backend/src/Data/Association.cs
new file mode 100644
index 000000000..e45524dd7
--- /dev/null
+++ b/backend/src/Data/Association.cs
@@ -0,0 +1,8 @@
+namespace Metabase.Data;
+
+public abstract class Association
+{
+ // Configured via `IsRowVersion` in `ApplicationDbContext` instead of the annotation
+ // [Timestamp]
+ public uint Version { get; private set; } // https://www.npgsql.org/efcore/modeling/concurrency.html
+}
\ No newline at end of file
diff --git a/backend/src/Data/AuditableAssociation.cs b/backend/src/Data/AuditableAssociation.cs
new file mode 100644
index 000000000..680b89c46
--- /dev/null
+++ b/backend/src/Data/AuditableAssociation.cs
@@ -0,0 +1,10 @@
+using System;
+
+namespace Metabase.Data;
+
+public abstract class AuditableAssociation
+: Association, IAuditable
+{
+ public DateTimeOffset CreatedAt { get; set; }
+ public DateTimeOffset UpdatedAt { get; set; }
+}
\ No newline at end of file
diff --git a/backend/src/Data/AuditableEntity.cs b/backend/src/Data/AuditableEntity.cs
new file mode 100644
index 000000000..f6b87de99
--- /dev/null
+++ b/backend/src/Data/AuditableEntity.cs
@@ -0,0 +1,20 @@
+using System;
+
+namespace Metabase.Data;
+
+public abstract class AuditableEntity
+: Entity, IAuditable
+{
+ public AuditableEntity()
+ : base()
+ {
+ }
+
+ public AuditableEntity(Guid id)
+ : base(id)
+ {
+ }
+
+ public DateTimeOffset CreatedAt { get; set; }
+ public DateTimeOffset UpdatedAt { get; set; }
+}
\ No newline at end of file
diff --git a/backend/src/Data/Component.cs b/backend/src/Data/Component.cs
index 19c7d93f1..6ce11ae40 100644
--- a/backend/src/Data/Component.cs
+++ b/backend/src/Data/Component.cs
@@ -10,7 +10,8 @@
namespace Metabase.Data;
public sealed class Component
- : Entity
+ : AuditableEntity,
+ INamed
{
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
public Component()
diff --git a/backend/src/Data/ComponentAssembly.cs b/backend/src/Data/ComponentAssembly.cs
index be482278e..4000dc364 100644
--- a/backend/src/Data/ComponentAssembly.cs
+++ b/backend/src/Data/ComponentAssembly.cs
@@ -4,7 +4,9 @@
namespace Metabase.Data;
public sealed class ComponentAssembly
+: AuditableAssociation, IAssociation
{
+
public Guid AssembledComponentId { get; set; }
public Component AssembledComponent { get; set; } = default!;
diff --git a/backend/src/Data/ComponentConcretizationAndGeneralization.cs b/backend/src/Data/ComponentConcretizationAndGeneralization.cs
index 7d880f10f..41cc8b0f1 100644
--- a/backend/src/Data/ComponentConcretizationAndGeneralization.cs
+++ b/backend/src/Data/ComponentConcretizationAndGeneralization.cs
@@ -3,6 +3,7 @@
namespace Metabase.Data;
public sealed class ComponentConcretizationAndGeneralization
+: AuditableAssociation, IAssociation
{
public Guid GeneralComponentId { get; set; }
public Component GeneralComponent { get; set; } = default!;
diff --git a/backend/src/Data/ComponentManufacturer.cs b/backend/src/Data/ComponentManufacturer.cs
index af51c467a..b1ab4e4ce 100644
--- a/backend/src/Data/ComponentManufacturer.cs
+++ b/backend/src/Data/ComponentManufacturer.cs
@@ -3,6 +3,7 @@
namespace Metabase.Data;
public sealed class ComponentManufacturer
+: AuditableAssociation, IAssociation
{
public Guid ComponentId { get; set; }
public Component Component { get; set; } = default!;
diff --git a/backend/src/Data/ComponentVariant.cs b/backend/src/Data/ComponentVariant.cs
index e7b572614..d06047106 100644
--- a/backend/src/Data/ComponentVariant.cs
+++ b/backend/src/Data/ComponentVariant.cs
@@ -1,8 +1,10 @@
+using EntityFrameworkCore.Projectables;
using System;
namespace Metabase.Data;
public sealed class ComponentVariant
+: AuditableAssociation, IAssociation
{
public Guid OfComponentId { get; set; }
public Component OfComponent { get; set; } = default!;
diff --git a/backend/src/Data/DataCopnstants.cs b/backend/src/Data/DataCopnstants.cs
new file mode 100644
index 000000000..d8950aa06
--- /dev/null
+++ b/backend/src/Data/DataCopnstants.cs
@@ -0,0 +1,16 @@
+namespace Metabase.Data;
+
+public static class DataConstants
+{
+ public const string TestlabOpenIdConnectClientId = "testlab-solar-facades";
+ public const string IgsdbOpenIdConnectClientId = "igsdb";
+
+ public const string IseInstitutionUuid = "5320d6fb-b96d-4aeb-a24c-eb7036d3437a";
+ public const string TestlabInstitutionUuid = "82b9f95c-3261-463a-90fe-0e9da707af17";
+ public const string LbnlInstitutionUuid = "c17af5ef-2f1d-4c73-bcc9-fcfb722420f3";
+ public const string EpeaInstitutionUuid = "84580047-d1db-4275-bd29-4222c907cabe";
+
+ public const string TestlabDatabaseUuid = "8a27aa0d-6026-4124-b185-4efd5cead953";
+ public const string IgsdbDatabaseUuid = "48994b60-670d-488d-aaf7-53333a64f1d6";
+ public const string EpeaDatabaseUuid = "82490800-1712-48d0-8b4a-2ca821487c8d";
+}
\ No newline at end of file
diff --git a/backend/src/Data/DataFormat.cs b/backend/src/Data/DataFormat.cs
index 2108f2643..3e53af3d4 100644
--- a/backend/src/Data/DataFormat.cs
+++ b/backend/src/Data/DataFormat.cs
@@ -5,7 +5,8 @@
namespace Metabase.Data;
public sealed class DataFormat
- : Entity
+ : AuditableEntity,
+ INamed
{
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
public DataFormat()
diff --git a/backend/src/Data/Database.cs b/backend/src/Data/Database.cs
index dba9c25ad..50f8a2f66 100644
--- a/backend/src/Data/Database.cs
+++ b/backend/src/Data/Database.cs
@@ -7,7 +7,8 @@
namespace Metabase.Data;
public sealed class Database
- : Entity
+ : AuditableEntity,
+ INamed
{
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
public Database()
diff --git a/backend/src/Data/DbSeeder.cs b/backend/src/Data/DbSeeder.cs
index fe5df256a..4a1bc9e0b 100644
--- a/backend/src/Data/DbSeeder.cs
+++ b/backend/src/Data/DbSeeder.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using Metabase.Authentication;
using Metabase.Data.OpenIdConnect;
@@ -60,9 +61,6 @@ string scope
public sealed class DbSeeder
{
- public const string TestlabSolarFacadesOpenIdConnectClientId = "testlab-solar-facades";
- public const string IgsdbOpenIdConnectClientId = "igsdb";
-
public static readonly ReadOnlyCollection<(string Name, string EmailAddress, Enumerations.UserRole Role)> Users =
Role.AllEnum.Select(role => (
Role.EnumToName(role),
@@ -72,21 +70,19 @@ public sealed class DbSeeder
public static readonly (string Name, string EmailAddress, Enumerations.UserRole Role)
AdministratorUser =
- Users.First(x => x.Role == Enumerations.UserRole.ADMINISTRATOR);
+ Users.First(_ => _.Role == Enumerations.UserRole.ADMINISTRATOR);
public static readonly (string Name, string EmailAddress, Enumerations.UserRole Role)
VerifierUser =
- Users.First(x => x.Role == Enumerations.UserRole.VERIFIER);
-
- private const string IseInstitutionUuid = "5320d6fb-b96d-4aeb-a24c-eb7036d3437a";
- private const string TestlabInstitutionUuid = "82b9f95c-3261-463a-90fe-0e9da707af17";
- private const string LbnlInstitutionUuid = "c17af5ef-2f1d-4c73-bcc9-fcfb722420f3";
+ Users.First(_ => _.Role == Enumerations.UserRole.VERIFIER);
- private const string TestlabDatabaseUuid = "8a27aa0d-6026-4124-b185-4efd5cead953";
- private const string IgsdbDatabaseUuid = "48994b60-670d-488d-aaf7-53333a64f1d6";
+ public static readonly (string Name, string EmailAddress, Enumerations.UserRole Role)
+ SupporterUser =
+ Users.First(_ => _.Role == Enumerations.UserRole.SUPPORTER);
public static async Task DoAsync(
- IServiceProvider services
+ IServiceProvider services,
+ CancellationToken cancellationToken
)
{
var logger = services.GetRequiredService>();
@@ -94,11 +90,11 @@ IServiceProvider services
var environment = services.GetRequiredService();
var appSettings = services.GetRequiredService();
await CreateRolesAsync(services, logger);
- await CreateUsersAsync(services, environment, appSettings, logger);
- await CreateInstitutionsAsync(services, environment);
- await CreateDatabasesAsync(services, environment, appSettings);
- await CreateOpenIdConnectScopes(services, logger);
- await CreateOpenIdConnectApplications(services, logger, environment, appSettings);
+ await CreateUsersAsync(services, environment, appSettings, logger, cancellationToken);
+ await CreateInstitutionsAsync(services, environment, cancellationToken);
+ await CreateDatabasesAsync(services, environment, appSettings, cancellationToken);
+ await CreateOpenIdConnectScopes(services, logger, cancellationToken);
+ await CreateOpenIdConnectApplications(services, logger, environment, appSettings, cancellationToken);
}
private static async Task CreateRolesAsync(
@@ -123,15 +119,20 @@ private static async Task CreateUsersAsync(
IServiceProvider services,
IWebHostEnvironment environment,
AppSettings appSettings,
- ILogger logger
+ ILogger logger,
+ CancellationToken cancellationToken
)
{
+ var context = services.GetRequiredService();
var manager = services.GetRequiredService>();
if (environment.IsProduction())
{
- if ((await manager.GetUsersInRoleAsync(Role.Administrator)).Count == 0)
+ if (!await context.Users.AnyAsync(cancellationToken))
{
- await CreateUserAsync(manager, AdministratorUser, appSettings.BootstrapUserPassword, logger);
+ if ((await manager.GetUsersInRoleAsync(Role.Administrator)).Count is 0)
+ {
+ await CreateUserAsync(manager, AdministratorUser, appSettings.BootstrapUserPassword, logger);
+ }
}
}
else
@@ -140,7 +141,12 @@ ILogger logger
{
if (await manager.FindByEmailAsync(userInfo.EmailAddress) is null)
{
- await CreateUserAsync(manager, userInfo, appSettings.BootstrapUserPassword, logger);
+ await CreateUserAsync(
+ manager,
+ userInfo,
+ appSettings.BootstrapUserPassword,
+ logger
+ );
}
}
}
@@ -167,21 +173,22 @@ await manager.CreateAsync(
private static async Task CreateInstitutionsAsync(
IServiceProvider services,
- IWebHostEnvironment environment
+ IWebHostEnvironment environment,
+ CancellationToken cancellationToken
)
{
var manager = services.GetRequiredService>();
var context = services.GetRequiredService();
- var iseInstitution = await context.Institutions.Where(_ => _.Id == new Guid(IseInstitutionUuid)).SingleOrDefaultAsync();
+ var iseInstitution = await context.Institutions.Where(_ => _.Id == new Guid(DataConstants.IseInstitutionUuid)).SingleOrDefaultAsync(cancellationToken);
if (iseInstitution is null)
{
iseInstitution = new Institution(
- new Guid(IseInstitutionUuid),
+ new Guid(DataConstants.IseInstitutionUuid),
"Fraunhofer ISE",
"ISE",
"Fraunhofer Institute for Solar Energy Systems (ISE)",
new ContactInformation(
- phoneNumber: "+49 761 45880",
+ phoneNumber: "+4976145880",
isPhoneNumberConfirmed: true,
postalAddress: "Heidenhofstraße 2, 79110 Freiburg im Breisgau",
emailAddress: null,
@@ -195,137 +202,174 @@ IWebHostEnvironment environment
iseInstitution.RepresentativeEdges.Add(
new InstitutionRepresentative
{
- UserId = (await context.Users.Where(x => x.Email == AdministratorUser.EmailAddress).SingleAsync()).Id,
+ UserId = (await context.Users.Where(_ => _.Email == AdministratorUser.EmailAddress).SingleAsync(cancellationToken)).Id,
Role = InstitutionRepresentativeRole.OWNER,
Pending = false
}
);
- var application = await manager.FindByClientIdAsync(OpenIdConnectConstants.Client.MetabaseClientId).AsTask();
+ var application = await manager.FindByClientIdAsync(OpenIdConnectConstants.Client.MetabaseClientId, cancellationToken).AsTask();
if (application is not null)
{
iseInstitution.OpenIdConnectApplications.Add(application);
}
context.Institutions.Add(iseInstitution);
- await context.SaveChangesAsync();
}
- if (environment.IsDevelopment())
+ if (!await context.Institutions.Where(_ => _.Id == new Guid(DataConstants.TestlabInstitutionUuid)).AnyAsync(cancellationToken))
{
- if (!await context.Institutions.Where(x => x.Id == new Guid(TestlabInstitutionUuid)).AnyAsync())
+ var institution = new Institution(
+ new Guid(DataConstants.TestlabInstitutionUuid),
+ "TestLab Solar Facades",
+ "TLSF",
+ "This institution represents the TestLab Solar Facades of Fraunhofer ISE",
+ new ContactInformation(
+ phoneNumber: "+4976145885673",
+ isPhoneNumberConfirmed: true,
+ postalAddress: "Heidenhofstraße 2, 79110 Freiburg im Breisgau",
+ emailAddress: null,
+ isEmailAddressConfirmed: false,
+ websiteLocator: new Uri("https://www.ise.fraunhofer.de/en/rd-infrastructure/accredited-labs/testlab-solar-facades.html", UriKind.Absolute)
+ ),
+ InstitutionState.VERIFIED,
+ InstitutionOperatingState.OPERATING,
+ null
+ )
{
- var institution = new Institution(
- new Guid(TestlabInstitutionUuid),
- "TestLab Solar Facades",
- "TLSF",
- "This institution represents the TestLab Solar Facades of Fraunhofer ISE",
- new ContactInformation(
- phoneNumber: "+49 761 4588-5673",
- isPhoneNumberConfirmed: true,
- postalAddress: "Heidenhofstraße 2, 79110 Freiburg im Breisgau",
- emailAddress: null,
- isEmailAddressConfirmed: false,
- websiteLocator: new Uri("https://www.ise.fraunhofer.de/en/rd-infrastructure/accredited-labs/testlab-solar-facades.html", UriKind.Absolute)
- ),
- InstitutionState.VERIFIED,
- InstitutionOperatingState.OPERATING,
- null
- )
- {
- ManagerId = iseInstitution.Id
- };
-
- var application = await manager.FindByClientIdAsync(TestlabSolarFacadesOpenIdConnectClientId).AsTask();
- if (application is not null)
- {
- institution.OpenIdConnectApplications.Add(application);
- }
- context.Institutions.Add(institution);
- await context.SaveChangesAsync();
- }
- if (!await context.Institutions.Where(x => x.Id == new Guid(LbnlInstitutionUuid)).AnyAsync())
+ ManagerId = iseInstitution.Id
+ };
+ var application = await manager.FindByClientIdAsync(DataConstants.TestlabOpenIdConnectClientId, cancellationToken).AsTask();
+ if (application is not null)
{
- var institution = new Institution(
- new Guid(LbnlInstitutionUuid),
- "LBNL",
- "LBNL",
- "Lawrence Berkeley National Laboratory",
- new ContactInformation(
- phoneNumber: "(510) 486-4000",
- isPhoneNumberConfirmed: true,
- postalAddress: "1 Cyclotron Road, Berkeley, CA 94720",
- emailAddress: null,
- isEmailAddressConfirmed: false,
- websiteLocator: new Uri("https://www.lbl.gov", UriKind.Absolute)
- ),
- InstitutionState.VERIFIED,
- InstitutionOperatingState.OPERATING,
- null
- )
- {
- ManagerId = iseInstitution.Id
- };
- context.Institutions.Add(institution);
- await context.SaveChangesAsync();
+ institution.OpenIdConnectApplications.Add(application);
}
+ context.Institutions.Add(institution);
}
+ if (!await context.Institutions.Where(_ => _.Id == new Guid(DataConstants.LbnlInstitutionUuid)).AnyAsync(cancellationToken))
+ {
+ var institution = new Institution(
+ new Guid(DataConstants.LbnlInstitutionUuid),
+ "LBNL",
+ "LBNL",
+ "Lawrence Berkeley National Laboratory",
+ new ContactInformation(
+ phoneNumber: "+5104864000",
+ isPhoneNumberConfirmed: true,
+ postalAddress: "1 Cyclotron Road, Berkeley, CA 94720",
+ emailAddress: null,
+ isEmailAddressConfirmed: false,
+ websiteLocator: new Uri("https://www.lbl.gov", UriKind.Absolute)
+ ),
+ InstitutionState.VERIFIED,
+ InstitutionOperatingState.OPERATING,
+ null
+ )
+ {
+ ManagerId = iseInstitution.Id
+ };
+ context.Institutions.Add(institution);
+ }
+ if (!await context.Institutions.Where(_ => _.Id == new Guid(DataConstants.EpeaInstitutionUuid)).AnyAsync(cancellationToken))
+ {
+ var institution = new Institution(
+ new Guid(DataConstants.EpeaInstitutionUuid),
+ "EPEA - Part of Drees & Sommer",
+ "EPEA",
+ "Sustainability",
+ new ContactInformation(
+ phoneNumber: null,
+ isPhoneNumberConfirmed: false,
+ postalAddress: null,
+ emailAddress: null,
+ isEmailAddressConfirmed: false,
+ websiteLocator: null
+ ),
+ InstitutionState.VERIFIED,
+ InstitutionOperatingState.OPERATING,
+ null
+ )
+ {
+ ManagerId = iseInstitution.Id
+ };
+ context.Institutions.Add(institution);
+ }
+ await context.SaveChangesAsync(cancellationToken);
}
private static async Task CreateDatabasesAsync(
IServiceProvider services,
IWebHostEnvironment environment,
- AppSettings appSettings
+ AppSettings appSettings,
+ CancellationToken cancellationToken
)
{
- if (environment.IsDevelopment())
+ var context = services.GetRequiredService();
+ if (!await context.Databases.AnyAsync(cancellationToken))
{
- var context = services.GetRequiredService();
- if (!await context.Databases.Where(x => x.Id == new Guid(TestlabDatabaseUuid)).AnyAsync())
+ if (!await context.Databases.Where(_ => _.Id == new Guid(DataConstants.TestlabDatabaseUuid)).AnyAsync(cancellationToken))
{
var uriBuilder = new UriBuilder(appSettings.TestlabSolarFacades.Uri)
{
Path = "/graphql/"
};
var database = new Database(
- new Guid(TestlabDatabaseUuid),
+ new Guid(DataConstants.TestlabDatabaseUuid),
"TestLab DB",
"The database of the TestLab Solar Facades of Fraunhofer ISE",
uriBuilder.Uri
)
{
- OperatorId = new Guid(TestlabInstitutionUuid)
+ OperatorId = new Guid(DataConstants.TestlabInstitutionUuid)
};
database.Verify();
context.Databases.Add(database);
- await context.SaveChangesAsync();
}
- if (!await context.Databases.Where(x => x.Id == new Guid(IgsdbDatabaseUuid)).AnyAsync())
+ if (!await context.Databases.Where(_ => _.Id == new Guid(DataConstants.IgsdbDatabaseUuid)).AnyAsync(cancellationToken))
{
var uriBuilder = new UriBuilder(new Uri("https://igsdb-v2-staging.herokuapp.com", UriKind.Absolute))
{
Path = "/graphql/"
};
var database = new Database(
- new Guid(IgsdbDatabaseUuid),
+ new Guid(DataConstants.IgsdbDatabaseUuid),
"IGSDB",
"The International Glazing and Shading Database (IGSDB)",
uriBuilder.Uri
)
{
- OperatorId = new Guid(LbnlInstitutionUuid)
+ OperatorId = new Guid(DataConstants.LbnlInstitutionUuid)
+ };
+ database.Verify();
+ context.Databases.Add(database);
+ }
+ if (!await context.Databases.Where(_ => _.Id == new Guid(DataConstants.EpeaDatabaseUuid)).AnyAsync(cancellationToken))
+ {
+ var uriBuilder = new UriBuilder(new Uri("https://app.conpli.eu/GraphQL", UriKind.Absolute))
+ {
+ Path = "/graphql/"
+ };
+ var database = new Database(
+ new Guid(DataConstants.EpeaDatabaseUuid),
+ "ProCA Database",
+ "Database for Life-Cycle data of components",
+ uriBuilder.Uri
+ )
+ {
+ OperatorId = new Guid(DataConstants.EpeaInstitutionUuid)
};
database.Verify();
context.Databases.Add(database);
- await context.SaveChangesAsync();
}
+ await context.SaveChangesAsync(cancellationToken);
}
}
private static async Task CreateOpenIdConnectScopes(
IServiceProvider services,
- ILogger logger
+ ILogger logger,
+ CancellationToken cancellationToken
)
{
var manager = services.GetRequiredService>();
- if (await manager.FindByNameAsync(OpenIdConnectScope.ReadApiScope) is null)
+ if (await manager.FindByNameAsync(OpenIdConnectScope.ReadApiScope, cancellationToken) is null)
{
logger.CreatingScope(OpenIdConnectScope.ReadApiScope);
await manager.CreateAsync(
@@ -337,11 +381,12 @@ await manager.CreateAsync(
{
OpenIdConnectConstants.Client.MetabaseClientId
}
- }
+ },
+ cancellationToken
);
}
- if (await manager.FindByNameAsync(OpenIdConnectScope.WriteApiScope) is null)
+ if (await manager.FindByNameAsync(OpenIdConnectScope.WriteApiScope, cancellationToken) is null)
{
logger.CreatingScope(OpenIdConnectScope.WriteApiScope);
await manager.CreateAsync(
@@ -353,11 +398,12 @@ await manager.CreateAsync(
{
OpenIdConnectConstants.Client.MetabaseClientId
}
- }
+ },
+ cancellationToken
);
}
- if (await manager.FindByNameAsync(OpenIdConnectScope.AdministrateApiScope) is null)
+ if (await manager.FindByNameAsync(OpenIdConnectScope.AdministrateApiScope, cancellationToken) is null)
{
logger.CreatingScope(OpenIdConnectScope.AdministrateApiScope);
await manager.CreateAsync(
@@ -369,11 +415,12 @@ await manager.CreateAsync(
{
OpenIdConnectConstants.Client.MetabaseClientId
}
- }
+ },
+ cancellationToken
);
}
- if (await manager.FindByNameAsync(OpenIdConnectScope.VerifyApiScope) is null)
+ if (await manager.FindByNameAsync(OpenIdConnectScope.VerifyApiScope, cancellationToken) is null)
{
logger.CreatingScope(OpenIdConnectScope.VerifyApiScope);
await manager.CreateAsync(
@@ -385,11 +432,29 @@ await manager.CreateAsync(
{
OpenIdConnectConstants.Client.MetabaseClientId
}
- }
+ },
+ cancellationToken
+ );
+ }
+
+ if (await manager.FindByNameAsync(OpenIdConnectScope.SupportApiScope, cancellationToken) is null)
+ {
+ logger.CreatingScope(OpenIdConnectScope.SupportApiScope);
+ await manager.CreateAsync(
+ new OpenIddictScopeDescriptor
+ {
+ DisplayName = "Allow customer support role",
+ Name = OpenIdConnectScope.SupportApiScope,
+ Resources =
+ {
+ OpenIdConnectConstants.Client.MetabaseClientId
+ }
+ },
+ cancellationToken
);
}
- if (await manager.FindByNameAsync(OpenIdConnectScope.ManageUserApiScope) is null)
+ if (await manager.FindByNameAsync(OpenIdConnectScope.ManageUserApiScope, cancellationToken) is null)
{
logger.CreatingScope(OpenIdConnectScope.ManageUserApiScope);
await manager.CreateAsync(
@@ -401,11 +466,12 @@ await manager.CreateAsync(
{
OpenIdConnectConstants.Client.MetabaseClientId
}
- }
+ },
+ cancellationToken
);
}
- if (await manager.FindByNameAsync(OpenIdConnectScope.ManageOpenIdConnectApiScope) is null)
+ if (await manager.FindByNameAsync(OpenIdConnectScope.ManageOpenIdConnectApiScope, cancellationToken) is null)
{
logger.CreatingScope(OpenIdConnectScope.ManageOpenIdConnectApiScope);
await manager.CreateAsync(
@@ -417,11 +483,12 @@ await manager.CreateAsync(
{
OpenIdConnectConstants.Client.MetabaseClientId
}
- }
+ },
+ cancellationToken
);
}
- if (await manager.FindByNameAsync(OpenIdConnectScope.ManageInstitutionRepresentativeApiScope) is null)
+ if (await manager.FindByNameAsync(OpenIdConnectScope.ManageInstitutionRepresentativeApiScope, cancellationToken) is null)
{
logger.CreatingScope(OpenIdConnectScope.ManageInstitutionRepresentativeApiScope);
await manager.CreateAsync(
@@ -433,11 +500,12 @@ await manager.CreateAsync(
{
OpenIdConnectConstants.Client.MetabaseClientId
}
- }
+ },
+ cancellationToken
);
}
- if (await manager.FindByNameAsync(OpenIdConnectScope.ManageGnuPgApiScope) is null)
+ if (await manager.FindByNameAsync(OpenIdConnectScope.ManageGnuPgApiScope, cancellationToken) is null)
{
logger.CreatingScope(OpenIdConnectScope.ManageGnuPgApiScope);
await manager.CreateAsync(
@@ -449,11 +517,12 @@ await manager.CreateAsync(
{
OpenIdConnectConstants.Client.MetabaseClientId
}
- }
+ },
+ cancellationToken
);
}
- if (await manager.FindByNameAsync(OpenIdConnectScope.ManageDatabaseApiScope) is null)
+ if (await manager.FindByNameAsync(OpenIdConnectScope.ManageDatabaseApiScope, cancellationToken) is null)
{
logger.CreatingScope(OpenIdConnectScope.ManageDatabaseApiScope);
await manager.CreateAsync(
@@ -465,7 +534,8 @@ await manager.CreateAsync(
{
OpenIdConnectConstants.Client.MetabaseClientId
}
- }
+ },
+ cancellationToken
);
}
}
@@ -474,30 +544,33 @@ private static async Task CreateOpenIdConnectApplications(
IServiceProvider services,
ILogger logger,
IWebHostEnvironment environment,
- AppSettings appSettings
+ AppSettings appSettings,
+ CancellationToken cancellationToken
)
{
var context = services.GetRequiredService();
var manager = services.GetRequiredService>();
- if (await manager.FindByClientIdAsync(OpenIdConnectConstants.Client.MetabaseClientId) is null)
+ if (!await context.OpenIdConnectApplications.AnyAsync(cancellationToken))
{
- logger.CreatingApplicationClient(OpenIdConnectConstants.Client.MetabaseClientId);
- var host = appSettings.Uri;
- var descriptor = new OpenIddictApplicationDescriptor
+ if (await manager.FindByClientIdAsync(OpenIdConnectConstants.Client.MetabaseClientId, cancellationToken) is null)
{
- ClientId = OpenIdConnectConstants.Client.MetabaseClientId,
- ClientSecret = null,
- ConsentType = OpenIddictConstants.ConsentTypes.Explicit,
- DisplayName = "Metabase client application",
- RedirectUris =
+ logger.CreatingApplicationClient(OpenIdConnectConstants.Client.MetabaseClientId);
+ var host = appSettings.Uri;
+ var descriptor = new OpenIddictApplicationDescriptor
+ {
+ ClientId = OpenIdConnectConstants.Client.MetabaseClientId,
+ ClientSecret = null,
+ ConsentType = OpenIddictConstants.ConsentTypes.Explicit,
+ DisplayName = "Metabase",
+ RedirectUris =
{
new UriBuilder(host) { Path = "/connect/callback/login/metabase" }.Uri
},
- PostLogoutRedirectUris =
+ PostLogoutRedirectUris =
{
new UriBuilder(host) { Path = "/connect/callback/logout/metabase" }.Uri
},
- Permissions =
+ Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.EndSession,
@@ -509,45 +582,42 @@ AppSettings appSettings
OpenIddictConstants.Permissions.ResponseTypes.IdToken,
OpenIddictConstants.Permissions.ResponseTypes.Token,
},
- Requirements =
+ Requirements =
{
OpenIddictConstants.Requirements.Features.ProofKeyForCodeExchange,
OpenIddictConstants.Requirements.Features.PushedAuthorizationRequests,
}
+ }
+ .AddGrantTypePermissions(
+ environment.IsEnvironment(Program.TestEnvironment)
+ ? OpenIddictConstants.GrantTypes.Password
+ : OpenIddictConstants.GrantTypes.AuthorizationCode,
+ OpenIddictConstants.GrantTypes.ClientCredentials,
+ OpenIddictConstants.GrantTypes.RefreshToken,
+ OpenIddictConstants.GrantTypes.TokenExchange
+ )
+ .AddScopePermissions(OpenIdConnectScope.Scopes)
+ .AddAudiencePermissions(OpenIdConnectConstants.Client.MetabaseClientId)
+ .AddResourcePermissions(appSettings.GraphQlEndpoint.AbsoluteUri);
+ var application = new OpenIdConnectApplication
+ {
+ OwnerId = new Guid(DataConstants.IseInstitutionUuid)
+ };
+ await manager.PopulateAsync(application, descriptor, cancellationToken);
+ // The secret is used in tests, see `IntegrationTests#RequestAuthToken` and in
+ // the metabase client, see `OPEN_ID_CONNECT_CLIENT_SECRET` in `.env.*`.
+ await manager.CreateAsync(application, appSettings.OpenIdConnectClientSecret, cancellationToken);
}
- .AddGrantTypePermissions(
- environment.IsEnvironment(Program.TestEnvironment)
- ? OpenIddictConstants.GrantTypes.Password
- : OpenIddictConstants.GrantTypes.AuthorizationCode,
- OpenIddictConstants.GrantTypes.ClientCredentials,
- OpenIddictConstants.GrantTypes.RefreshToken,
- OpenIddictConstants.GrantTypes.TokenExchange
- )
- .AddScopePermissions(OpenIdConnectScope.Scopes)
- .AddAudiencePermissions(OpenIdConnectConstants.Client.MetabaseClientId)
- .AddResourcePermissions(appSettings.GraphQlEndpoint.AbsoluteUri);
- var application = new OpenIdConnectApplication
+ if (await manager.FindByClientIdAsync(DataConstants.TestlabOpenIdConnectClientId, cancellationToken) is null)
{
- OwnerId = new Guid(IseInstitutionUuid)
- };
- await manager.PopulateAsync(application, descriptor);
- // The secret is used in tests, see `IntegrationTests#RequestAuthToken` and in
- // the metabase client, see `OPEN_ID_CONNECT_CLIENT_SECRET` in `.env.*`.
- await manager.CreateAsync(application, appSettings.OpenIdConnectClientSecret);
- }
-
- if (environment.IsDevelopment())
- {
- if (await manager.FindByClientIdAsync(TestlabSolarFacadesOpenIdConnectClientId) is null)
- {
- logger.CreatingApplicationClient(TestlabSolarFacadesOpenIdConnectClientId);
+ logger.CreatingApplicationClient(DataConstants.TestlabOpenIdConnectClientId);
var host = appSettings.TestlabSolarFacades.Uri;
var descriptor = new OpenIddictApplicationDescriptor
{
- ClientId = TestlabSolarFacadesOpenIdConnectClientId,
+ ClientId = DataConstants.TestlabOpenIdConnectClientId,
ClientSecret = null,
ConsentType = OpenIddictConstants.ConsentTypes.Explicit,
- DisplayName = "Testlab-Solar-Facades client application",
+ DisplayName = "TestLab Solar Façades",
RedirectUris =
{
new UriBuilder(host) { Path = "/connect/callback/login/metabase" }.Uri
@@ -586,23 +656,22 @@ AppSettings appSettings
.AddAudiencePermissions(OpenIdConnectConstants.Client.MetabaseClientId);
var application = new OpenIdConnectApplication
{
- OwnerId = new Guid(TestlabInstitutionUuid)
+ OwnerId = new Guid(DataConstants.TestlabInstitutionUuid)
};
- await manager.PopulateAsync(application, descriptor);
+ await manager.PopulateAsync(application, descriptor, cancellationToken);
// The secret is used in the database client, see
// `OPEN_ID_CONNECT_CLIENT_SECRET` in `.env.*`.
- await manager.CreateAsync(application, appSettings.TestlabSolarFacades.OpenIdConnectClientSecret);
+ await manager.CreateAsync(application, appSettings.TestlabSolarFacades.OpenIdConnectClientSecret, cancellationToken);
}
-
- if (await manager.FindByClientIdAsync(IgsdbOpenIdConnectClientId) is null)
+ if (await manager.FindByClientIdAsync(DataConstants.IgsdbOpenIdConnectClientId, cancellationToken) is null)
{
- logger.CreatingApplicationClient(IgsdbOpenIdConnectClientId);
+ logger.CreatingApplicationClient(DataConstants.IgsdbOpenIdConnectClientId);
var descriptor = new OpenIddictApplicationDescriptor
{
- ClientId = IgsdbOpenIdConnectClientId,
+ ClientId = DataConstants.IgsdbOpenIdConnectClientId,
ClientSecret = null,
ConsentType = OpenIddictConstants.ConsentTypes.Explicit,
- DisplayName = "IGSDB client application",
+ DisplayName = "IGSDB",
RedirectUris = { },
PostLogoutRedirectUris = { },
Permissions =
@@ -630,10 +699,10 @@ AppSettings appSettings
.AddAudiencePermissions(OpenIdConnectConstants.Client.MetabaseClientId);
var application = new OpenIdConnectApplication
{
- OwnerId = new Guid(LbnlInstitutionUuid)
+ OwnerId = new Guid(DataConstants.LbnlInstitutionUuid)
};
- await manager.PopulateAsync(application, descriptor);
- await manager.CreateAsync(application, appSettings.Igsdb.OpenIdConnectClientSecret);
+ await manager.PopulateAsync(application, descriptor, cancellationToken);
+ await manager.CreateAsync(application, appSettings.Igsdb.OpenIdConnectClientSecret, cancellationToken);
}
}
}
diff --git a/backend/src/Data/Entity.cs b/backend/src/Data/Entity.cs
index 7be5f6be8..d9bc8b56f 100644
--- a/backend/src/Data/Entity.cs
+++ b/backend/src/Data/Entity.cs
@@ -1,7 +1,5 @@
using System;
-// using System.ComponentModel.DataAnnotations.Schema;
-
namespace Metabase.Data;
public abstract class Entity
@@ -16,7 +14,7 @@ public Entity(Guid id)
Id = id;
}
- public Guid Id { get; private set; }
+ public Guid Id { get; init; }
// [NotMapped]
// public Guid Uuid { get => Id; }
diff --git a/backend/src/Data/GnuPgKeyFingerprint.cs b/backend/src/Data/GnuPgKeyFingerprint.cs
index 351786454..57cef02be 100644
--- a/backend/src/Data/GnuPgKeyFingerprint.cs
+++ b/backend/src/Data/GnuPgKeyFingerprint.cs
@@ -11,8 +11,8 @@ namespace Metabase.Data;
[Index(nameof(Fingerprint), IsUnique = true)]
public sealed partial class GnuPgKeyFingerprint(
string fingerprint
- )
- : Entity
+)
+: AuditableEntity
{
[GeneratedRegex("[^A-F0-9]")]
private static partial Regex HexadecimalRegex();
@@ -27,7 +27,6 @@ public static string Normalize(string dirtyFingerprint)
[Required][MinLength(1)] public string Fingerprint { get; private set; } = Normalize(fingerprint);
- [Required] public OffsetDateTime CreatedAt { get; private set; } = OffsetDateTime.UtcNow;
public OffsetDateTime? AllowedAt { get; private set; }
public OffsetDateTime? ForbiddenAt { get; private set; }
@@ -39,19 +38,19 @@ public static string Normalize(string dirtyFingerprint)
[InverseProperty(nameof(Institution.GnuPgKeyFingerprints))]
public Institution? Institution { get; set; }
- public void Allow()
+ public void Allow(IClock clock)
{
- AllowedAt ??= OffsetDateTime.UtcNow;
+ AllowedAt ??= clock.GetUtcNow();
}
- public void Forbid()
+ public void Forbid(IClock clock)
{
// If this fingerprint has not been allowed for approval yet before it
// shall be forbidden now, we set `AllowedAt` and `ForbiddenAt` to
// the present moment making its total validity range the half closed
// interval `[AllowedAt, ForbiddenAt)` empty. This makes sure that
// whenever `ForbiddenAt` is set, `AllowedAt` is also set.
- var now = OffsetDateTime.UtcNow;
+ var now = clock.GetUtcNow();
AllowedAt ??= now;
ForbiddenAt ??= now;
}
diff --git a/backend/src/Data/IAssociation.cs b/backend/src/Data/IAssociation.cs
new file mode 100644
index 000000000..d57b7a2d8
--- /dev/null
+++ b/backend/src/Data/IAssociation.cs
@@ -0,0 +1,7 @@
+namespace Metabase.Data;
+
+public interface IAssociation
+{
+ // Configured via `[Timestamp]` in `Association`
+ public uint Version { get; } // https://www.npgsql.org/efcore/modeling/concurrency.html
+}
\ No newline at end of file
diff --git a/backend/src/Data/IAuditable.cs b/backend/src/Data/IAuditable.cs
new file mode 100644
index 000000000..e87fd4650
--- /dev/null
+++ b/backend/src/Data/IAuditable.cs
@@ -0,0 +1,13 @@
+using System;
+
+namespace Metabase.Data;
+
+public interface IAuditable
+{
+ // TODO Switch to NodaTime `OffsetDateTime` once there is a `ICursorKeySerializer` implementation for it. Then sorting by `CreatedAt` and `UpdatedAt` with pagination will keep working.
+ public DateTimeOffset CreatedAt { get; set; }
+ public DateTimeOffset UpdatedAt { get; set; }
+
+ // soft delete
+ // public Instant? DeletedAt { get; set; }
+}
\ No newline at end of file
diff --git a/backend/src/Data/IMethodDeveloper.cs b/backend/src/Data/IMethodDeveloper.cs
index d5553f614..562cc24b9 100644
--- a/backend/src/Data/IMethodDeveloper.cs
+++ b/backend/src/Data/IMethodDeveloper.cs
@@ -8,6 +8,7 @@ namespace Metabase.Data;
[JsonDerivedType(typeof(UserMethodDeveloper), typeDiscriminator: nameof(UserMethodDeveloper))]
[JsonDerivedType(typeof(InstitutionMethodDeveloper), typeDiscriminator: nameof(InstitutionMethodDeveloper))]
public interface IMethodDeveloper
+: IAuditable, IAssociation
{
public Guid MethodId { get; }
public Method Method { get; }
diff --git a/backend/src/Data/INamed.cs b/backend/src/Data/INamed.cs
new file mode 100644
index 000000000..2427f2a47
--- /dev/null
+++ b/backend/src/Data/INamed.cs
@@ -0,0 +1,6 @@
+namespace Metabase.Data;
+
+public interface INamed
+{
+ public string Name { get; }
+}
\ No newline at end of file
diff --git a/backend/src/Data/Institution.cs b/backend/src/Data/Institution.cs
index 1d2134158..84862d026 100644
--- a/backend/src/Data/Institution.cs
+++ b/backend/src/Data/Institution.cs
@@ -9,7 +9,9 @@
namespace Metabase.Data;
public sealed class Institution
-: Entity, IStakeholder
+: AuditableEntity,
+ IStakeholder,
+ INamed
{
// #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
// public Institution()
diff --git a/backend/src/Data/InstitutionMethodDeveloper.cs b/backend/src/Data/InstitutionMethodDeveloper.cs
index 3bd55ab9c..53580295c 100644
--- a/backend/src/Data/InstitutionMethodDeveloper.cs
+++ b/backend/src/Data/InstitutionMethodDeveloper.cs
@@ -3,10 +3,11 @@
namespace Metabase.Data;
public sealed class InstitutionMethodDeveloper
- : IMethodDeveloper
+: AuditableAssociation, IMethodDeveloper, IAssociation
{
public Guid InstitutionId { get; set; }
public Institution Institution { get; set; } = default!;
+
public Guid MethodId { get; set; }
public Method Method { get; set; } = default!;
diff --git a/backend/src/Data/InstitutionRepresentative.cs b/backend/src/Data/InstitutionRepresentative.cs
index 32c81d931..e6ab400eb 100644
--- a/backend/src/Data/InstitutionRepresentative.cs
+++ b/backend/src/Data/InstitutionRepresentative.cs
@@ -5,6 +5,7 @@
namespace Metabase.Data;
public sealed class InstitutionRepresentative
+: AuditableAssociation, IAssociation
{
public Guid InstitutionId { get; set; }
public Institution Institution { get; set; } = default!;
diff --git a/backend/src/Data/Method.cs b/backend/src/Data/Method.cs
index fcc72eafa..539ac6535 100644
--- a/backend/src/Data/Method.cs
+++ b/backend/src/Data/Method.cs
@@ -10,7 +10,8 @@
namespace Metabase.Data;
public sealed class Method
- : Entity
+: AuditableEntity,
+ INamed
{
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
public Method()
diff --git a/backend/src/Data/OpenIdConnect/IOpenIdConnectSubject.cs b/backend/src/Data/OpenIdConnect/IOpenIdConnectSubject.cs
new file mode 100644
index 000000000..457825c5a
--- /dev/null
+++ b/backend/src/Data/OpenIdConnect/IOpenIdConnectSubject.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Globalization;
+using System.Text.Json.Serialization;
+using System.Threading.Tasks;
+using Metabase.GraphQl;
+
+namespace Metabase.Data.OpenIdConnect;
+
+[JsonPolymorphic(TypeDiscriminatorPropertyName = GraphQlConstants.TypeDiscriminatorPropertyName)]
+[JsonDerivedType(typeof(User), typeDiscriminator: nameof(User))]
+[JsonDerivedType(typeof(OpenIdConnectApplication), typeDiscriminator: nameof(OpenIdConnectApplication))]
+public interface IOpenIdConnectSubject
+{
+ private const string ClientSubjectPrefix = "client:";
+
+ public static string BuildUserSubject(Guid userId) =>
+ userId.ToString("D");
+
+ public static string BuildClientSubject(string clientId) =>
+ $"{ClientSubjectPrefix}{clientId}";
+
+ public static Task SwitchSubjectAsync(
+ string? subject,
+ Func> handleUserId,
+ Func> handleClientId,
+ Func> handleUnknownSubject
+ )
+ {
+ if (subject is null)
+ {
+ return handleUnknownSubject();
+ }
+ if (subject.StartsWith(ClientSubjectPrefix, ignoreCase: false, culture: CultureInfo.InvariantCulture))
+ {
+ return handleClientId(subject[ClientSubjectPrefix.Length..]);
+ }
+ if (Guid.TryParse(subject, out Guid userId))
+ {
+ return handleUserId(userId);
+ }
+ return handleUnknownSubject();
+ }
+}
\ No newline at end of file
diff --git a/backend/src/Data/OpenIdConnect/OpenIdConnectApplication.cs b/backend/src/Data/OpenIdConnect/OpenIdConnectApplication.cs
index 3323d8d77..6f39fbafd 100644
--- a/backend/src/Data/OpenIdConnect/OpenIdConnectApplication.cs
+++ b/backend/src/Data/OpenIdConnect/OpenIdConnectApplication.cs
@@ -1,19 +1,24 @@
using System;
-using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using OpenIddict.EntityFrameworkCore.Models;
namespace Metabase.Data.OpenIdConnect;
public sealed class OpenIdConnectApplication
- : OpenIddictEntityFrameworkCoreApplication,
- IEntity
+: OpenIddictEntityFrameworkCoreApplication,
+ IEntity,
+ IAuditable,
+ IOpenIdConnectSubject
{
public Guid OwnerId { get; set; }
[InverseProperty(nameof(Institution.OpenIdConnectApplications))]
public Institution Owner { get; set; } = null!;
- [Timestamp]
+ public DateTimeOffset CreatedAt { get; set; }
+ public DateTimeOffset UpdatedAt { get; set; }
+
+ // Configured via `IsRowVersion` in `ApplicationDbContext` instead of the annotation
+ // [Timestamp]
public uint Version { get; private set; } // https://www.npgsql.org/efcore/modeling/concurrency.html
}
\ No newline at end of file
diff --git a/backend/src/Data/OpenIdConnect/OpenIdConnectAuthorization.cs b/backend/src/Data/OpenIdConnect/OpenIdConnectAuthorization.cs
index c9c87c533..704cccc9a 100644
--- a/backend/src/Data/OpenIdConnect/OpenIdConnectAuthorization.cs
+++ b/backend/src/Data/OpenIdConnect/OpenIdConnectAuthorization.cs
@@ -1,13 +1,18 @@
using System;
-using System.ComponentModel.DataAnnotations;
using OpenIddict.EntityFrameworkCore.Models;
namespace Metabase.Data.OpenIdConnect;
public sealed class OpenIdConnectAuthorization
: OpenIddictEntityFrameworkCoreAuthorization,
- IEntity
+ IEntity,
+ IAuditable
{
- [Timestamp]
+ // `createdAt` could be an alias of `creationDate`
+ public DateTimeOffset CreatedAt { get; set; }
+ public DateTimeOffset UpdatedAt { get; set; }
+
+ // Configured via `IsRowVersion` in `ApplicationDbContext` instead of the annotation
+ // [Timestamp]
public uint Version { get; private set; } // https://www.npgsql.org/efcore/modeling/concurrency.html
}
\ No newline at end of file
diff --git a/backend/src/Data/OpenIdConnect/OpenIdConnectScope.cs b/backend/src/Data/OpenIdConnect/OpenIdConnectScope.cs
index 2c93717d2..4cc0b6c5a 100644
--- a/backend/src/Data/OpenIdConnect/OpenIdConnectScope.cs
+++ b/backend/src/Data/OpenIdConnect/OpenIdConnectScope.cs
@@ -1,5 +1,4 @@
using System;
-using System.ComponentModel.DataAnnotations;
using OpenIddict.Abstractions;
using OpenIddict.EntityFrameworkCore.Models;
@@ -7,7 +6,8 @@ namespace Metabase.Data.OpenIdConnect;
public sealed class OpenIdConnectScope
: OpenIddictEntityFrameworkCoreScope,
- IEntity
+ IEntity,
+ IAuditable
{
private const string ScopeSeparator = ":";
@@ -20,6 +20,8 @@ public sealed class OpenIdConnectScope
public const string AdministrateApiScope = ApiScopePrefix + ScopeSeparator + "administrate";
// Allow elevated privileges for users with role "Verifier"
public const string VerifyApiScope = ApiScopePrefix + ScopeSeparator + "verify";
+ // Allow elevated privileges for users with role "Supporter"
+ public const string SupportApiScope = ApiScopePrefix + ScopeSeparator + "support";
public const string ManageDatabaseApiScope = ApiScopePrefix + ScopeSeparator + "database" + ScopeSeparator + "manage";
public const string ManageGnuPgApiScope = ApiScopePrefix + ScopeSeparator + "gnu_pg" + ScopeSeparator + "manage";
public const string ManageInstitutionRepresentativeApiScope = ApiScopePrefix + ScopeSeparator + "institution_representative" + ScopeSeparator + "manage";
@@ -38,6 +40,7 @@ public sealed class OpenIdConnectScope
WriteApiScope,
AdministrateApiScope,
VerifyApiScope,
+ SupportApiScope,
ManageDatabaseApiScope,
ManageGnuPgApiScope,
ManageInstitutionRepresentativeApiScope,
@@ -45,6 +48,10 @@ public sealed class OpenIdConnectScope
ManageUserApiScope,
];
- [Timestamp]
+ public DateTimeOffset CreatedAt { get; set; }
+ public DateTimeOffset UpdatedAt { get; set; }
+
+ // Configured via `IsRowVersion` in `ApplicationDbContext` instead of the annotation
+ // [Timestamp]
public uint Version { get; private set; } // https://www.npgsql.org/efcore/modeling/concurrency.html
}
\ No newline at end of file
diff --git a/backend/src/Data/OpenIdConnect/OpenIdConnectToken.cs b/backend/src/Data/OpenIdConnect/OpenIdConnectToken.cs
index b62ba3a28..d9c7b7d54 100644
--- a/backend/src/Data/OpenIdConnect/OpenIdConnectToken.cs
+++ b/backend/src/Data/OpenIdConnect/OpenIdConnectToken.cs
@@ -1,13 +1,18 @@
using System;
-using System.ComponentModel.DataAnnotations;
using OpenIddict.EntityFrameworkCore.Models;
namespace Metabase.Data.OpenIdConnect;
public sealed class OpenIdConnectToken
: OpenIddictEntityFrameworkCoreToken,
- IEntity
+ IEntity,
+ IAuditable
{
- [Timestamp]
+ // `createdAt` could be an alias of `creationDate`
+ public DateTimeOffset CreatedAt { get; set; }
+ public DateTimeOffset UpdatedAt { get; set; }
+
+ // Configured via `IsRowVersion` in `ApplicationDbContext` instead of the annotation
+ // [Timestamp]
public uint Version { get; private set; } // https://www.npgsql.org/efcore/modeling/concurrency.html
}
\ No newline at end of file
diff --git a/backend/src/Data/Publication.cs b/backend/src/Data/Publication.cs
index 886c9d430..be6a0de5d 100644
--- a/backend/src/Data/Publication.cs
+++ b/backend/src/Data/Publication.cs
@@ -1,6 +1,7 @@
using System;
using System.ComponentModel.DataAnnotations;
using HotChocolate;
+using Metabase.GraphQl.Scalars;
using Microsoft.EntityFrameworkCore;
namespace Metabase.Data;
@@ -23,20 +24,23 @@ public sealed class Publication(
[MinLength(1)]
[RegularExpression("^(10[.][0-z/.]*)$")]
[GraphQLDescription("The Digital Object Identifier (DOI) is a very important persistent identifier for publications. It MUST be defined here if it is available for a publication.")]
+ [GraphQLType]
public string? Doi { get; private set; } = doi;
[MinLength(1)]
[RegularExpression("^(arXiv:)[0-z./]*$")]
[GraphQLDescription("The website arXiv.org is a free and open-access archive for publications. The arXiv identifier can be used to define a publication.")]
+ [GraphQLType]
public string? ArXiv { get; private set; } = arXiv;
[MinLength(1)]
[RegularExpression("^(urn:)[0-z:./-]*$")]
- [GraphQLDescription("A Uniform Resource Name (URN) can be used to define a publication. TODO: Improve the regex pattern to further restrict the string.")]
+ [GraphQLDescription("A Uniform Resource Name (URN) can be used to define a publication.")]
+ [GraphQLType]
public string? Urn { get; private set; } = urn;
[Url]
- [GraphQLDescription("If a persistent identifier like DOI is defined above, this webAdress can define a convenient web address to access the publication. However, if no persistent identifier exist, this web address is the only identifier of this publication. In this case, it is important to choose a web address with a high probability to persist long.")]
+ [GraphQLDescription("If a persistent identifier like DOI is defined above, this web address can define a convenient web address to access the publication. However, if no persistent identifier exist, this web address is the only identifier of this publication. In this case, it is important to choose a web address with a high probability to persist long.")]
public Uri? WebAddress { get; private set; } = webAddress;
[MinLength(1)] public string? Title { get; private set; } = title;
diff --git a/backend/src/Data/Role.cs b/backend/src/Data/Role.cs
index 920dde358..9e907a3b2 100644
--- a/backend/src/Data/Role.cs
+++ b/backend/src/Data/Role.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.ObjectModel;
using Microsoft.AspNetCore.Identity;
-using Guid = System.Guid;
namespace Metabase.Data;
@@ -9,12 +8,14 @@ public sealed class Role : IdentityRole
{
public const string Administrator = "Administrator";
private const string Verifier = "Verifier";
+ private const string Supporter = "Supporter";
public static readonly ReadOnlyCollection AllEnum =
Array.AsReadOnly(
[
Enumerations.UserRole.ADMINISTRATOR,
- Enumerations.UserRole.VERIFIER
+ Enumerations.UserRole.VERIFIER,
+ Enumerations.UserRole.SUPPORTER
]);
// public ICollection UserRoles { get; } = new List();
@@ -39,6 +40,7 @@ public static string EnumToName(Enumerations.UserRole role)
{
Enumerations.UserRole.ADMINISTRATOR => Administrator,
Enumerations.UserRole.VERIFIER => Verifier,
+ Enumerations.UserRole.SUPPORTER => Supporter,
_ => throw new ArgumentOutOfRangeException(nameof(role), $"Unknown role `{role}.`")
};
}
@@ -49,6 +51,7 @@ public static Enumerations.UserRole EnumFromName(string name)
{
Administrator => Enumerations.UserRole.ADMINISTRATOR,
Verifier => Enumerations.UserRole.VERIFIER,
+ Supporter => Enumerations.UserRole.SUPPORTER,
_ => throw new ArgumentOutOfRangeException(nameof(name), $"Unknown name `{name}.`")
};
}
diff --git a/backend/src/Data/User.cs b/backend/src/Data/User.cs
index 0bf8130ec..49decc61b 100644
--- a/backend/src/Data/User.cs
+++ b/backend/src/Data/User.cs
@@ -4,15 +4,18 @@
using System.ComponentModel.DataAnnotations.Schema;
using HotChocolate;
using Microsoft.AspNetCore.Identity;
-using Guid = System.Guid;
+using Metabase.Data.OpenIdConnect;
// TODO Make `User`, `Role`, ... subtype `Entity` and use `Version` to catch update conflicts. Add interface `IEntity`.
namespace Metabase.Data;
public sealed class User
- : IdentityUser,
- IEntity,
- IStakeholder
+: IdentityUser,
+ IEntity,
+ IAuditable,
+ INamed,
+ IStakeholder,
+ IOpenIdConnectSubject
{
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
public User()
@@ -65,5 +68,8 @@ public User(
[InverseProperty(nameof(GnuPgKeyFingerprint.User))]
public ICollection GnuPgKeyFingerprints { get; } = [];
+ public DateTimeOffset CreatedAt { get; set; }
+ public DateTimeOffset UpdatedAt { get; set; }
+
public uint Version { get; private set; } // https://www.npgsql.org/efcore/modeling/concurrency.html
}
\ No newline at end of file
diff --git a/backend/src/Data/UserMethodDeveloper.cs b/backend/src/Data/UserMethodDeveloper.cs
index aeda7885d..b81ff2275 100644
--- a/backend/src/Data/UserMethodDeveloper.cs
+++ b/backend/src/Data/UserMethodDeveloper.cs
@@ -3,10 +3,11 @@
namespace Metabase.Data;
public sealed class UserMethodDeveloper
- : IMethodDeveloper
+: AuditableAssociation, IMethodDeveloper, IAssociation
{
public Guid UserId { get; set; }
public User User { get; set; } = default!;
+
public Guid MethodId { get; set; }
public Method Method { get; set; } = default!;
diff --git a/backend/src/Enumerations/UserRole.cs b/backend/src/Enumerations/UserRole.cs
index e4147b47d..c483604c5 100644
--- a/backend/src/Enumerations/UserRole.cs
+++ b/backend/src/Enumerations/UserRole.cs
@@ -4,4 +4,5 @@ public enum UserRole
{
ADMINISTRATOR,
VERIFIER,
+ SUPPORTER
}
\ No newline at end of file
diff --git a/backend/src/Extensions/EnumExtensions.cs b/backend/src/Extensions/EnumExtensions.cs
new file mode 100644
index 000000000..c568bb1ab
--- /dev/null
+++ b/backend/src/Extensions/EnumExtensions.cs
@@ -0,0 +1,20 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.Contracts;
+using System.Linq;
+using System.Reflection;
+
+namespace Metabase.Extensions;
+
+public static class EnumExtensions
+{
+ [Pure]
+ public static string GetDescription(this Enum value)
+ {
+ return value.GetType()
+ .GetMember(value.ToString())
+ .FirstOrDefault()
+ ?.GetCustomAttribute()
+ ?.Description ?? value.ToString();
+ }
+}
\ No newline at end of file
diff --git a/backend/src/Extensions/HttpContextExtensions.cs b/backend/src/Extensions/HttpContextExtensions.cs
index 19806daa4..a44280384 100644
--- a/backend/src/Extensions/HttpContextExtensions.cs
+++ b/backend/src/Extensions/HttpContextExtensions.cs
@@ -1,5 +1,6 @@
using System;
using System.Linq;
+using System.Diagnostics.Contracts;
using Metabase.Authentication;
using Microsoft.AspNetCore.Http;
@@ -10,13 +11,14 @@ public static class HttpContextExtensions
public static void SetBearerToken(this HttpContext httpContext, string accessToken) =>
httpContext.Request.Headers.Authorization = $"{OpenIdConnectConstants.AuthorizationHeaderBearer} {accessToken}";
+ [Pure]
public static string? ExtractBearerToken(this HttpContext httpContext)
{
var bearerTokenPrefix = $"{OpenIdConnectConstants.AuthorizationHeaderBearer} ";
return httpContext.Request?.Headers?.Authorization
.FirstOrDefault(
- x => x is not null
- && x.TrimStart().StartsWith(bearerTokenPrefix, StringComparison.Ordinal)
+ _ => _ is not null
+ && _.TrimStart().StartsWith(bearerTokenPrefix, StringComparison.Ordinal)
)
?.TrimStart()
?[bearerTokenPrefix.Length..]
diff --git a/backend/src/Extensions/LinqExtensions.cs b/backend/src/Extensions/LinqExtensions.cs
new file mode 100644
index 000000000..69ca73f94
--- /dev/null
+++ b/backend/src/Extensions/LinqExtensions.cs
@@ -0,0 +1,190 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.Contracts;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Runtime.InteropServices;
+
+namespace Metabase.Extensions;
+
+public enum OrderDirection
+{
+ ASCENDING,
+ DESCENDING
+}
+
+public static class LinqExtensions
+{
+ [Pure]
+ public static IEnumerable If(
+ this IEnumerable source,
+ bool condition,
+ Func, IEnumerable> transform
+ )
+ {
+ return condition ? transform(source) : source;
+ }
+
+ [Pure]
+ public static IQueryable If(
+ this IQueryable source,
+ bool condition,
+ Func,
+ IQueryable> transform
+ )
+ {
+ return condition ? transform(source) : source;
+ }
+
+ [Pure]
+ public static List IfList(
+ this List source,
+ bool condition,
+ Func, List> transform
+ )
+ {
+ return condition ? transform(source) : source;
+ }
+
+ [Pure]
+ public static List ToReversed(this List source)
+ {
+ var copy = new List(source);
+ copy.Reverse();
+ return copy;
+ }
+
+ [Pure]
+ public static T? GetAtOrDefault(this T[] array, int index, T? defaultValue = default) where T : class
+ {
+ return (index >= 0 && index < array.Length) ? array[index] : defaultValue;
+ }
+
+ [Pure]
+ public static T? GetFirstOrDefault(this T[] array) where T : class
+ {
+ return array.Length > 0 ? array[0] : default;
+ }
+
+ [Pure]
+ public static T? GetAtOrDefault(this IReadOnlyList list, int index) where T : class
+ {
+ return (index >= 0 && index < list.Count) ? list[index] : default;
+ }
+
+ [Pure]
+ public static T? GetFirstOrDefault(this IReadOnlyList list) where T : class
+ {
+ return list.Count > 0 ? list[0] : default;
+ }
+
+ [Pure]
+ public static T? GetLastOrDefault(this IReadOnlyList list) where T : class
+ {
+ return list.Count > 0 ? list[^1] : default;
+ }
+
+ [Pure]
+ public static IEnumerable NotNull(this IEnumerable enumerable) where T : class
+ {
+ return enumerable.Where(item => item is not null).Select(item => item!);
+ }
+
+ [Pure]
+ public static IEnumerable NotNull(this IEnumerable enumerable) where T : struct
+ {
+ return enumerable.Where(item => item.HasValue).Select(item => item!.Value);
+ }
+
+ [Pure]
+ public static IOrderedQueryable OrderByDirection(
+ this IQueryable source,
+ Expression> keySelector,
+ OrderDirection direction
+ )
+ {
+ return direction is OrderDirection.ASCENDING
+ ? source.OrderBy(keySelector)
+ : source.OrderByDescending(keySelector);
+ }
+
+
+ [Pure]
+ public static IEnumerable Interleave(this IEnumerable> sequences)
+ {
+ var enumerators = new LinkedList>();
+ try
+ {
+ foreach (var sequence in sequences)
+ {
+ var enumerator = sequence.GetEnumerator();
+ if (enumerator.MoveNext())
+ {
+ enumerators.AddLast(enumerator);
+ yield return enumerator.Current;
+ }
+ else
+ {
+ enumerator.Dispose();
+ }
+ }
+ var node = enumerators.First;
+ while (node is { Value: var enumerator, Next: var nextNode })
+ {
+ if (enumerator.MoveNext())
+ {
+ yield return enumerator.Current;
+ }
+ else
+ {
+ enumerators.Remove(node);
+ enumerator.Dispose();
+ }
+ node = nextNode ?? enumerators.First;
+ }
+ }
+ finally
+ {
+ foreach (var enumerator in enumerators)
+ enumerator.Dispose();
+ }
+ }
+
+ [Pure]
+ public static IEnumerable Scan(
+ this IEnumerable source,
+ TAccumulate seed,
+ Func function)
+ {
+ var accumulate = seed;
+ foreach (var item in source)
+ {
+ (accumulate, var result) = function(accumulate, item);
+ yield return result;
+ }
+ }
+
+ [Pure]
+ public static List Rotate(
+ this List list,
+ Predicate after
+ )
+ where T : class
+ {
+ if (list.Count is 0)
+ {
+ return list;
+ }
+ var afterIndex = list.FindIndex(after);
+ if (afterIndex is -1)
+ {
+ return list;
+ }
+ var index = (afterIndex + 1) % list.Count;
+ var result = new List(list.Count);
+ var span = CollectionsMarshal.AsSpan(list);
+ result.AddRange(span.Slice(index));
+ result.AddRange(span.Slice(0, index));
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/backend/src/Extensions/NodaTimeExtensions.cs b/backend/src/Extensions/NodaTimeExtensions.cs
index fea856524..6fae7cb24 100644
--- a/backend/src/Extensions/NodaTimeExtensions.cs
+++ b/backend/src/Extensions/NodaTimeExtensions.cs
@@ -1,21 +1,31 @@
+using System.Diagnostics.Contracts;
using NodaTime;
namespace Metabase.Extensions;
public static class NodaTimeExtensions
{
- extension(OffsetDateTime)
+ [Pure]
+ public static OffsetDateTime GetUtcNow(this IClock clock)
{
- public static OffsetDateTime UtcNow =>
- SystemClock.Instance
- .GetCurrentInstant()
- .WithOffset(Offset.Zero);
+ return clock.GetCurrentInstant().WithOffset(Offset.Zero);
+ }
+ [Pure]
+ public static int CompareTo(this OffsetDateTime current, OffsetDateTime other)
+ {
+ return OffsetDateTime.Comparer.Instant.Compare(current, other);
+ }
+
+ extension(OffsetDateTime)
+ {
+ [Pure]
public static bool operator >(OffsetDateTime x, OffsetDateTime y)
{
return OffsetDateTime.Comparer.Instant.Compare(x, y) > 0;
}
+ [Pure]
public static bool operator <(OffsetDateTime x, OffsetDateTime y)
{
return OffsetDateTime.Comparer.Instant.Compare(x, y) < 0;
diff --git a/backend/src/Extensions/StringExtensions.cs b/backend/src/Extensions/StringExtensions.cs
index 62aeeb098..736499685 100644
--- a/backend/src/Extensions/StringExtensions.cs
+++ b/backend/src/Extensions/StringExtensions.cs
@@ -1,13 +1,48 @@
+using System;
+using System.Text;
+using System.Diagnostics.Contracts;
+
namespace Metabase.Extensions;
public static class StringExtensions
{
- public static string FirstCharToLower(this string str)
+ [Pure]
+ public static string FirstCharToLower(this string value)
+ {
+ return string.IsNullOrEmpty(value)
+ || !char.IsLetter(value, 0)
+ || char.IsLower(value, 0)
+ ? value
+ : char.ToLowerInvariant(value[0]) + value[1..];
+ }
+
+ [Pure]
+ public static string? NullIfEmpty(this string value)
+ => string.IsNullOrEmpty(value) ? null : value;
+
+ [Pure]
+ public static string? NullIfWhitespace(this string value)
+ => string.IsNullOrWhiteSpace(value) ? null : value;
+
+ [Pure]
+ public static string Base64Encode(this string plainText)
+ {
+ return Convert.ToBase64String(
+ Encoding.UTF8.GetBytes(plainText)
+ );
+ }
+
+ [Pure]
+ public static string Base64Decode(this string base64EncodedData)
+ {
+ return Encoding.UTF8.GetString(
+ Convert.FromBase64String(base64EncodedData)
+ );
+ }
+
+ [Pure]
+ public static string Enquote(this string str)
{
- return string.IsNullOrEmpty(str)
- || !char.IsLetter(str, 0)
- || char.IsLower(str, 0)
- ? str
- : char.ToLowerInvariant(str[0]) + str[1..];
+ return "\"" + str + "\"";
}
}
\ No newline at end of file
diff --git a/backend/src/GraphQl/Associations/AssociationType.cs b/backend/src/GraphQl/Associations/AssociationType.cs
new file mode 100644
index 000000000..dc28a194e
--- /dev/null
+++ b/backend/src/GraphQl/Associations/AssociationType.cs
@@ -0,0 +1,23 @@
+using HotChocolate.Types;
+using Metabase.Data;
+using Metabase.GraphQl.Scalars;
+
+namespace Metabase.GraphQl.Associations;
+
+public abstract class AssociationType