diff --git a/docs/design-principles/0025-modularity.md b/docs/design-principles/0025-modularity.md new file mode 100644 index 00000000..59c0f736 --- /dev/null +++ b/docs/design-principles/0025-modularity.md @@ -0,0 +1,172 @@ +# Modularity + +How should we construct and define our modules (so that we can develop/test them together), and how should we deploy them into a staging/production environment? + +## Design Principles + +1. While we are a small team (e.g., a single product team or several product teams working on the same release cadence), for as long as possible, we want to be working on the same codebase so that its whole design can evolve as a single unit. This is one case of using a ["Mono-Repo"](https://en.wikipedia.org/wiki/Monorepo). +2. We want to (as much as possible) run, test, and debug the entire system or the most relevant sub-system of the system. +3. As a business [survives and] grows, the complexity of the software naturally increases as the software supports more functionality and as the subdomain models are explored in deeper detail. More use cases crop up (i.e., more features), and the demand and load on the software starts to increase (i.e., more users/growth). Due to either of these causes (or all of them), typically, the performance of the software decreases as more and more components/modules come into existence and more and more need to interact with each other. Bottlenecks on shared infrastructure are common. To improve that performance, the components/modules of the system are required to be split and scaled independently (e.g., towards independent microservices, with dedicated infrastructure). Eventually, it will become necessary to split the system components in the backend to serve more clients in the frontend. (Note: Typically, frontends are only split by channel or audience, whereas backends tend to be split by subdomain or by load/demand). We do not want to have to re-engineer the system to split it up; the mechanism to split it up should already be in place and can require additional work to connect the split pieces together once split. This is precisely the case for using a ["Modular Monolith"](../decisions/0010-deployment-model.md). +4. We've [already decided](../decisions/0045-modularization.md) that we will structure the backend into subdomains (and use DDD structures/practices), and those will be the base unit of our modules. +5. The modules/subdomains in the system at present are defined using the `ISubDomainModule` abstraction and collated into individual Host projects (e.g, web projects or executables) using `SubDomainModules`, which are then packaged and deployed onto respective hosts/infrastructure in the cloud (e.g., into an "App Service" in Azure, or into a "Lambda" or "EC2 instance" in AWS). When they are deployed in the same deployed Host (i.e., into the same running process), then they can communicate with each other (through ports and adapters) with "in-process" adapters. When they are deployed into separate Hosts/infrastructure, they must communicate across HTTP, using the same port, but using a different HTTP adapter. Thus, even though these submodules can be deployed and scaled independently (to improve performance and reduce bottlenecks), the additional communication between them now increases latency and decreases reliability between them. See the [CAP Theorem](https://en.wikipedia.org/wiki/CAP_theorem) for the introduced challenges this brings. +6. While this codebase starts off with defining and deploying 1 single Backend API and 1 single Website, running alongside numerous other infrastructure components (i.e, queues, functions/lambdas, databases, etc), it is anticipated that at some time in the future, that more client applications will be built for this specific product (e.g, more websites or more mobile apps, or appliances, etc) and that the API will be split into several API Hosts (for salability), and then routed together using some kind of API/Application Gateway to appear as a single IP address. + +![Modular Monolith](../images/Physical-Architecture-Azure.png) + +## Implementation + +### A Backend API Host + +At present, we define and deploy 1 single `ApiHost1` project. + +On Azure, this would be [typically] deployed to a single "Azure App Service". On AWS, this could be deployed as a Lambda, or as an EC2 instance. + +> In both cloud providers, there are other options for deploying this `ApiHost1` project, for example, in Docker Containers, Kubernetes, etc. These would require additional development for your product. + +### A Module + +A logical "module" in this codebase is intended to be independently deployable, and is composed of several parts: + +1. The code for a module is defined in an `ISubDomainModule`, which is then collated into a `SubDomainModules` in a physical host project like `ApiHost1.HostedModules`. All extensibility and dependency injection, related to this module, is declared in the `ISubDomainModule` +2. The host project (i.e., `ApiHost`) configures itself by defining its `WebHostOptions`, which in turn drives the configuration of the ASPNET process, and pipelines (via the call to `WebApplicationBuilder.ConfigureApiHost` in `Program.cs`). All dependency injection and setup for the host is contained in call to `ConfigureApiHost`. +3. Configuration settings are declared in the host project, in a collection of `appsettings.json` files, some that are environment-specific and others that are host-specific, respective to the components in the module. +4. Data that the subdomain "owns" (or originates) is intentionally organized separately, from other subdomains, right down to how it is stored in their respective repositories (e.g., event stores, databases, caches, etc). + +> Note: When it comes to data repositories like relational databases (where joining data is a native capability), traditionally, developers working in a monolith are used to creating joins/dependencies between tables across the whole schema. This entangles the individual tables in larger database, making splitting modules later an extremely hard, if not impossible, task. Thus, with a Modular Monolith, extra special care has to be taken not to just reuse tables across individual subdomains but to keep them entirely separate (possibly duplicating some data). So that those tables pertaining to a single module can be either moved (or copied) to other database deployments without breaking the system or the integrity of the data. +> +> Note: To be a micro-service, you must be able to maintain and control your own data in a separate infrastructure, from other micro-services. + +### Splitting API Hosts + +> How to split up Modular Monolith into Deployable Hosts? + +Later in the development of your SaaS product, it will become necessary to split the code and data of your modular monolith, possibly due to performance/complexity issues, possibly due to a change in your organization (e.g., [Conway's Law](https://en.wikipedia.org/wiki/Conway%27s_law)), possibly due to a change in strategy. + +Whatever the case is, you will need to split up the code, data, and services of your single API Host `ApiHost1`. + +> Warning: There are several subdomains that work very, very closely together (and depend on each other to operate well), and should not be split readily. For example, the subdomains `EndUser`, `Organization,` and `Profile` subdomains form the core of the "multi-tenancy" capability. Splitting these subdomains can be done technically, but it is assumed that it cannot be done without incurring significant performance degradation (albeit that assumption has not been done nor been proven). + +#### Creating a new Host + +To make this easier, here are the steps you will need to split `ApiHost1` into another host, called `ApiHost2`: + +1. Create a new Host project called `ApiHost2`. Copy the entire project from `ApiHost1` and rename it to `ApiHost2`. + +2. Files that you will need to change (in order): + + 1. `Properties/launchsettings.json` - + 1. Rename all the tasks + 2. Assign a new `applicationUrl` (IP address and host) for both local, and production environments. + 2. Remove the `Api/TestingOnly` folder. + 1. Leave the `Api/Health` folder in place, but rename the type inside `HealthApi.cs`. + 3. Edit the `HostedModules.cs`, + 1. Remove any sub-modules that will NOT be hosted in this host. + 2. Remove the `TestingOnlyApiModule` + 3. Add any new modules for this host. + 4. Edit `Program.cs`, + 1. Change the namespace to `ApiHost2` + 2. Choose a different `WebHostOptions` option. You could consider using the `WebHostOptions.BackEndApiHost`, or define your own. + 5. Edit the `Resources.resx` file, + 1. Remove all settings for `GetTestingOnly*`. + 2. Consider deleting the file, if you have nothing to put in there. + 6. Delete the `tenantsettings.json` file, provided that you are NOT hosting the `OrganizationsModule` in this host. + 7. Delete the `TestingOnlyApiModule.cs` + +3. Now that you know more about what modules you will actually host in this project, change these files (in order): + + 1. Edit the `ApiHostModule.cs` file, and add any additional dependencies in `RegisterServices`. + 1. Note that all sub-modules should have already declared their dependencies themselves, so you may have nothing to do here. + + 1. Edit all the `appsettings.*.json` files, and remove any sections not needed by the modules you are hosting, or by the base configuration of the host. + +4. The last thing to do is take care of inter-host communication, as detailed below. + +#### Inter-Host Communication + +The next thing you will need to do is identify inter-host communication that is required between the modules of your new Host project and the modules of the other Host projects. Both, communication **from your host project** and communication **to your host project**. + +All communication between subdomains is done via ports and adapters. However, the adapters that you see in use in the `ApiHost` project will more than likely be taking advantage of the fact that they can run very fast (and more reliably) as "in-process" calls between the "Application Layers". This is a convenience. + +For example, the adapter used to access the `EndUser` subdomain from other subdomains (in `ApiHost`) is the `EndUsersInProcessServiceClient`, which is an adapter that assumes that the `EndUserModule` is deployed in the same host as the other module that is using the port `IEndUsersService` (e.g., the `Identity` subdomain). Thus, this in-process adapter uses the `IEndUserApplication` directly. + +This is all very convenient when both modules/subdomains are deployed in the same host. + +However, suppose you are splitting up the modules into different deployable hosts. In that case, those subdomains need to communicate using different adapters, which will need to be HTTP service clients as opposed to in-process service clients. + +For example, for subdomains (in another host) that need to communicate with the `Cars` subdomain through the `ICarsService` port, you must provide (and inject) an HTTP adapter, like the `CarsHttpServiceClient`. + +> Note: Don't forget to provide HTTP service client adapters in both directions, to and from your new host. +> +> Remember: Even though you may be communicating between several hosts now, as long as the hosts are deployed in the same data center as each other (assuming the same cloud provider), even though they are now using HTTP calls (instead of in-process calls), the speed of those calls should be very fast (perhaps sub ~100ms) as your hosts are likely to be hosted very close together (physically). Sometimes, the cloud provider keeps this latency optimized when components are physically close. But, reliability is still an issue. + +In your HTTP service client adapter, when making a remote HTTP call, you must relay several HTTP headers containing important information since now you are stringing together several HTTP calls between the independently deployed hosts. + +Two of those important details are: + +1. The Authorization token (JWT access_token) that was presented (for the specific user) to the first API host called by a client. (i.e., `ICallerContext.Authorization`) +2. A correlation ID that identifies the original request to the first API host and can be used to correlate calls to other hosts to enhance diagnostics. (i.e., `ICallerContext.CallId`) + +Other context can be added, but these two pieces are critical in order for different API hosts to collaborate effectively. + +Your HTTP service client adapters should, therefore, use the `IServiceClient` port to communicate between different hosts, and you should inject the `InterHostServiceClient` adapter to make these calls. + +> Note: The `InterHostServiceClient` will automatically forward the headers, as described above, and implement a retry policy (exponential backoff with jitter) to try and contact the other remote host. + +#### Internal versus External Communication + +Now, HTTP service clients are going to require "public" APIs to be created to support inter-host communication as described above. Many of these APIs do not exist in the codebase, since we've applied the YAGNI rule in NOT building them. + +Furthermore, some of these API calls will not be intended to be used by any "untrusted" clients. + +For example, we don't want anyone on the internet connecting to our API and making a direct call to an API that, say, returns the sensitive tenant-specific settings used for connecting to and querying a specific tenant's database. But we may need to have a "private" API that does give that data to a subdomain deployed on another host, that may need it. + +These "private" API calls should not be publicly documented or advertised (i.e., in SwaggerUI) nor accessible to just any client on the internet, but they do need to be accessible to HTTP from trusted parties. They should be protected with some further mechanism that can only be used by other sanctioned/trusted hosts that we control. + +In a "public" API call, we can make the call and include the necessary headers, and the host will treat this call like any other "public" API call, whether that call originated from a client directly or via another host (e.g., involved in a multi-step saga of some kind). + +In a "private" API call, we still want to impersonate the original user that made the originating API call (that we are now relaying to another host), but we also need a further level of authentication to identify the sanctioned host forwarding the call as a "private" API call. As opposed to a client making this call directly. + +> Note: Direct calls to "private" APIs cannot be allowed from clients directly, only from sanctioned and trusted hosts. + +The mechanism that must be used to enforce this on "private" API calls can be implemented in infrastructure by an API gateway, VPN, or other common networking infrastructure. And/Or it can be enforced in the application using a mechanism like HMAC authentication or a similar mechanism that sends a shared secret in the request between the hosts that is validated by the destination host. + +For "private" API requests, you declare them with the `isPrivate` option on the `RouteAttribute`. + +For example, + +```c# +[Route("/organizations/{id}/settings", ServiceOperation.Get, AccessType.Token, isPrivate = true)] +[Authorize(Roles.Platform_Operations)] +public class GetOrganizationSettingsRequest : TenantedRequest +{ + public required string Id { get; set; } +} +``` + +> Note: The above request is secured for `AccessType.Token` which means that a call to this API must include a Bearer token to identify the calling user. But, this endpoint is also marked as "private," so it must also include "private" API protection information as well. + +#### Authentication & Authorization + +As described above, when making inter-host communication, it is important to forward authentication information (like the original JWT access_token) between hosts, so that the host can identify the original calling user. + +Beyond that, there is not much else to do. + +All hosts will implement the same authorization mechanisms, which may, in some cases, require the host to communicate with other hosts. + +#### Multi-tenancy + +A final note of multi-tenancy. Implementation [details can be found here](0130-multitenancy.md). + +In order to support multi-tenancy, each inbound request needs to identify the specific tenant it is destined for, with some kind of tenant ID. + +This tenant ID can be determined in a number of ways, and the de facto mechanism is to include an `OrganizationId` in the request body or query string. + +> Other options include using host headers, or custom domains. + +In any case, resolving the given tenant ID to an existing bona fide organization, and then ensuring that the caller (if identified) has a membership to that organization is an important validation step in the request pipeline., for every inbound call. + +Since this validation process is required on many of the endpoints of most "tenanted" subdomains, access to the `Organizations` and `EndUser` subdomains will be required to complete this process. + +If those subdomains are hosted in other hosts (from the `Organizations` and `EndUser` subdomains), the API calls that need to be made may incur significant overhead overall. This can lead to [fan out](https://en.wikipedia.org/wiki/Fan-out_(software)) and performance degradation. + +There are solutions to this problem, primarily in caching these kinds of checks, which may be required when splitting subdomains into different hosts, depending on which subdomains are split out. diff --git a/docs/design-principles/0040-configuration.md b/docs/design-principles/0040-configuration.md index d6c3558c..e83a2b42 100644 --- a/docs/design-principles/0040-configuration.md +++ b/docs/design-principles/0040-configuration.md @@ -1,46 +1,60 @@ # Configuration +Every SaaS system is driven by configuration. + +In order to run these systems is different environments (e.g., `Local`, `Staging` and `Production`), different configuration is required, so that we can protect ourselves when developing a running system. + ## Design Principles -1. We want all components in each host to have access to static configuration (read-only), that would be set at deployment time. -2. We want that configuration to be written into packaged assets just before deployment, in the CD pipeline. -3. We want that configuration to be specific to a specific environment (e.g. Local development, Staging, Holding or Production) -4. We do not want developers writing anything but local development environment settings (secrets or otherwise) into configuration files. With one exception: the configuration used to configure components for integration testing against real 3rd party systems (i.e. in tests of the category: `Integration.External`). These 3rd party accounts/environments, should never be related to production environments, and are designed only for testing-only. Configuration (and especially any secrets) used for these accounts/environments can NEVER lead those with access to them to compromise the system or its integrity. -5. We will need some configuration for the SaaS "platform" (all shared components), and some configuration for each "tenant" running on the platform. These two sets of configuration must be kept separate for each other, but may not be stored in the same repositories. (e.g. platform configuration is defined in appsettings.json, whilst tenancy configuration is stored in a database) -6. Configuration needs to be hierarchical (e.g. namespaced), and hierarchical in terms of layering. -7. Settings are expected to be of only 3 types: `string`, `number` and `boolean` +1. We want all components in each runtime host (e.g., `ApiHost1`) to have access to static configuration (read-only), that would be set at deployment time. (i.e., in static files like `appsettings.json`) +2. We want that configuration to be over-written into static packaged assets (like `appsettings.json`) just before deployment, in the CD pipeline. Rather then as design time by a developer, so we avoid storing these settings in source code, or expose them to unprivileged people designing the system. +3. We want that configuration to be specific to a specific environment (e.g. `Local` development, `Staging`, or `Production`) +4. We do not want developers writing anything but `local` development environment settings (secrets or otherwise) into configuration files. With one exception, see note below. +5. We will need some "shared" configuration for the SaaS "platform" (used by all components), and some "private" configuration used by each "tenant" running on the platform. These two sets of configuration must be kept separate from each other, and may not be stored in the same repositories. (e.g. platform configuration is defined in `appsettings.json`, whilst tenancy configuration is stored in a data stores) +6. Configuration needs to be hierarchical (e.g. it can be grouped by namespace), and hierarchical in terms of layering. +7. Settings are expected to be of only 3 kinds: `string`, `number` and `boolean` 8. Components are responsible for reading their own configuration, and shall not re-use other components configuration. 9. Secrets may be stored separately from non-confidential configuration in other repositories (e.g. files, databases, 3rd party services). 10. We want to be able to change storage location of configuration at any time, without breaking code (e.g. files, databases, 3rd party services). 11. We want to use dependency injection to give components their configuration. +> Point 4 above, with one exception: the configuration used to configure components for integration testing against real 3rd party systems (i.e. in tests of the category: `Integration.External`). These 3rd party accounts/environments, should never be related to production environments, and are designed only for testing-only. Configuration (and especially any secrets) used for these accounts/environments can NEVER lead those with access to them to compromise the system or its integrity. + ## Implementation -The `IConfigurationSettings` abstraction is used to give access to configuration for both "Platform" settings and "Tenancy" settings. +The `IConfigurationSettings` abstraction is used to give access to configuration for both `Platform` settings and `Tenancy` settings. + +It is injected into any adapters that require access to any configuration settings. + +In order to operate effectively at runtime, the selection mechanism of whether to use a `Tenancy` setting or whether to use a `Platform` setting must be dynamic at runtime and must be dependent on whether the actual inbound HTTP request is destined for a specific tenant or not. + +For these reasons, the `AspNetDynamicConfigurationSettings` adapter is to be used. + +> This adapter is injected as both a "singleton" and a "scoped" in the DI container, depending on what adapters need which version of it. ### Platform Settings -Platform settings are setting that are shared across all components running in the platform. +Platform settings are "shared" across all components running in the platform, regardless of the lifetime of the dependency. For example: * Connection strings to centralized repositories (for hosting data pertaining to all tenants on the platform) -* Account details for accessing shared 3rd party system accounts via adapters (e.g. an email provider) +* Account details for accessing shared 3rd party system accounts via adapters (e.g., an email provider) * Keys and defaults for various application and domain services -Most of these settings will be stored in standard places that are supported by the .NET runtime, such as `appsettings.json` files for the specific environment. +> Most of these settings will be stored in standard places that are supported by the .NET runtime, such as `appsettings.json` files for the specific environment. ### Tenancy Settings -Tenancy settings are setting that are specific to a tenant running on the platform. +Tenancy settings are "private" and are specific to a tenant running on the platform, they are only applicable to "scoped" dependencies. For example: -* Connection strings to a tenant's physically partitioned repository (e.g. in a nearby datacenter of their choice) -* Account details for accessing a specific 3rd party system account via adapters (e.g. an accounting integration) +* Connection strings to a tenant's physically partitioned repository (e.g., in a nearby data center of their choice) +* Account details for accessing a specific 3rd party system account via adapters (e.g., an accounting integration) -At runtime, in a multi-tenanted host, when the inbound HTTP request is destined for an API that is tenanted, the `ITenantContext` will define the tenancy and settings for the current HTTP request. +At runtime, in a multi-tenanted host, when the inbound HTTP request is destined for an API that is tenanted, the `ITenantContext` will define the tenancy and it will define the `ITenancyContext.Settings` for the current HTTP request. -These settings are generally read from a dynamic repository (e.g. a database, or 3rd party service), and they are unique to the specific tenant. +These settings are read from the `IOrganizationsRepository` (i.e., a data store), and can be updated by other APIs. -> Never to be accidentally accessed by or exposed to other tenants running on the platform \ No newline at end of file +> These settings are never to be accidentally accessed by or exposed to other tenants running on the platform. \ No newline at end of file diff --git a/docs/design-principles/0110-back-end-for-front-end.md b/docs/design-principles/0110-back-end-for-front-end.md index 7bc3c9c6..e016123e 100644 --- a/docs/design-principles/0110-back-end-for-front-end.md +++ b/docs/design-principles/0110-back-end-for-front-end.md @@ -10,7 +10,9 @@ A web BEFFE is a web server designed specifically to serve a web application (i. A BEFFE is a de-coupling design strategy to avoid designing a Backend API that is directly coupled to a specific Frontend, or a Backend API that has to be all things to several Frontend applications (e.g., a web application, an admin web application, a mobile application, and a desktop application), all of which have different needs for data processing. -> This frees the designer of the Backend to focus on designing REST APIs instead of RPC/CRUD APIs to only serve a single Frontend. +> This frees the developer/designer of the Backend to focus on designing REST APIs instead of RPC/CRUD APIs to only serve a single dedicated Frontend. + +In many contexts of evolving SaaS products, a BEFFE can act as an [Anti-Corruption Layer (ACL)](https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer) whenever the Backend needs to be re-designed/re-engineered for legacy reasons, as a business grows and starts taking onboard direct integrations with the backend. Or when the product evolves to be more of an integration platform. ## Design Principles diff --git a/docs/design-principles/0130-multitenancy.md b/docs/design-principles/0130-multitenancy.md new file mode 100644 index 00000000..410f351d --- /dev/null +++ b/docs/design-principles/0130-multitenancy.md @@ -0,0 +1,326 @@ +# Multi-tenancy + +Multi-tenancy is the ability of a single deployed version of software to handle multiple tenants/organizations/companies/groups at the same time and segregate their private data so that you don't have to deploy multiple versions of the software, one for each tenant. This is a key attribute of SaaS-based products, as opposed to other kinds of products. + +Some SaaS-based products are "single"-tenanted, where: + +* Multiple end-users of a single business entity (i.e., a single company) share one logical and/or physical *instance* of software running that operates on one shared data set (between the end-users). e.g., a desktop application connected to a central server. + +Most SaaS products are "multi"-tenanted, where: + +* Multiple organizations (tenants) with multiple end-users share one logical or physical instance of software running but have separate data sets. e.g., an online document store like Google Docs. + +> Note: There is wide variance on what "logical" and "physical" *instance* means in practice, for each piece of software. + +### A short history + +Multi-tenancy is largely about data segregation today, thanks to the cloud and the rise of SaaS. We no longer distribute most software products on physical media like CDs or DVDs. Instead, we predominantly build web apps or internet-connected mobile/desktop apps or devices. + +Before the cloud was widely adopted, companies and consumers did not trust software vendors to host and run their software for them, let alone store any of their data. Companies and consumers installed the software products in their own proprietary data-centers or on their own personal internet-connected machines. This process is known as "On-premise" installs of software products. The software itself was distributed by vendors on CDs and DVDs. The license keys came stamped on the box. + +This model satisfied the company's/consumer's need for control and ownership of their data, but it made upgrading the software very difficult and expensive for the software vendors. Each physical installation of the software in a company's/consumer's data center represented a single tenant of the software. Data for the application was stored locally on the machine where the software was installed, on in local networked infrastructure in the office. All the tenants (in all the data centers where the software was installed) represented a giant multi-tenanted product, but none of them were networked or shared the same physical environment nor the same data. + +Later, some software products allowed you to "save your data" centrally to the vendor's data centers, and this began the birth of multi-tenanted backend systems. That evolved quickly to the whole application being run centrally, storing its data centrally too. + +SaaS became more popular in the mid-late 2000s, when companies began to trust the software vendors more with their software installations and data. Typically, a SaaS business builds a software product and controls the software version and its distribution, and also stores their customer data centrally in their own data centers. Then we have the large cloud providers who provide the hosting environments and infrastructure to run the software and store data for those SaaS companies. + +Some SaaS companies permitted the large cloud providers to host both their software and their customer's data, while others hosted their products on their own cloud-based infrastructure. Some SaaS companies had to segregate a specific customer's data from other customers' data due to sovereignty issues. Thus, we entered an era of multi-tenanted architectures that needed to segregate their data physically between each customer using the software on their "platform". + +The word "platform" became the term to loosely describe a multi-tenanted system, where each tenant could manage their own virtual installation of the software. In most cases, they branded it as their own software. + +In SaaS, there is usually only one physical "version" of the software running, and the version is shared by all companies/consumers using the product at any one time, no matter where in the world they are consuming it from. Sharing and scaling one version of the software globally is easily achievable as long as the product is web-based. The real problems in SaaS happen when trying to segregate and manage customer data securely and efficiently. + +### Multi-tenancy in SaaS today + +#### Single-tenanted + +Refers to a business model where one or more users share the same data and services. + +This means that some data is private to each user, while some data can be public and shared by all users. In such a scenario, each user has control over their private data (e.g., on their own device/disk), while the product manages all public data. + +All data and third-party services that the product uses are physically deployed in a single set of shared infrastructure (e.g., a single database) + +This means that the software accesses this single instance of data and services on behalf of each user. + +#### Multi-Tenanted + +Refers to a business model where one or more users share the same data and services within their "tenant" using the software, but users from different tenants do not share data with each other. In such a scenario, data and services between tenants must be at least "logically" separated, if not "physically" separated, to avoid sharing/disclosure of information between tenants. + +This means that if data is stored in the same physical database, then that data is partitioned by identifiers. Or it could mean that data is physically separated into physically separate databases, with one physical instance for each tenant. This strategy limits the probably that data is leaked between tenants. + +The software accesses the data for each user on behalf of the tenant they belong to. In some systems, users are stored centrally, while tenants are stored separately, allowing users to have access to multiple tenants. This depends on the type of product being built. + +In essence, a 'Tenant' is a loose boundary around some data and services that is shared by one or more specific users and not others. + +A tenant can be scoped in a given digital product as any of these common concepts: a Country, a Company, a Business, an Organisation, a Workspace, a Project, a Party, a Team, and any other kind of group or grouping. + +> Depending on the specific product, all of these concepts can be manifested as separate tenants. + +## Design Principles + +* Multi-tenancy is an integral part of any B2B or B2C SaaS web business model. +* Build once, deploy once, maintain one version of the software, and roll upgrades continuously, since we fully control the distribution of the software. +* Never share any data between tenants, unless explicitly authorized by each tenant. Privacy is paramount. +* Each tenant will likely want to customize the software running with their tenancy. Sometimes, this is just branding (fonts, colors, logos etc.), other times, it is the rules and configuration of the software. +* Tenants will likely want to give access to their tenancy to people inside and outside their company. Some individuals (i.e., consultants) will have access to more than one tenancy at any time, regardless of whether they work for the company in the tenancy or for companies/independent bodies outside that company. +* Physical partitioning of data is an important solution in a global SaaS solution. + +## Implementation + +### Identity Model + +In SaaStack, we have a choice of how to model multitenancy out of the box. The choices we have *essentially* comes down to whether we want to define identities **exclusively** within a tenant, or define identities **centrally** outside all tenancies (and shared with all tenancies across the platform). + +In B2B and B2C SaaS products, it is common to model identity either way. SaaStack has to make a choice in order to be a functional template however. + +> Note: This decision is not baked in concrete, and can be changed if necessary, but it cannot be both at the same time. + +This choice not only affects the rules around the relationships between users (`EndUser`), memberships and tenants (`Organization`), and where their data physically resides, it also defines the API contract used by those subdomains for all the use cases for managing identities. + +The choice SaaStack has made is to define all identities centrally, outside any given tenancy. + +The implication of this decision is that: + +* An `EndUser` in the system is unique across all tenancies by their username (their email address). That implies that a human using the system is the same human (by email) address, no matter what tenancy they are working in at any one time. (e.g., an independent consultant collaborating with more than one company). That human can of course use several email addresses, should it be necessary (due to rules/constraints) to access any specific tenancy only with a certain email domain. Which is a common requirement in many enterprise B2B products. +* Any `EndUser` can have any number of `Memberships` to any number of tenancies (`Organisations`), which is common for many B2B and B2C products. However, they must always have at least one (see later). +* The authentication (login) process, would be branded with the branding of the the SaaS product itself, since at that point in time, it is not clear which tenancy the user wishes to access. Which is common for a lot of B2C and B2B products. Think www.slack.com, or www.miro.com or www.google.com, etc, where you login to the unbranded product, before accessing the branded tenant. +* This also means that any `Enduser` will need to have a default "home" tenancy at all times, so that they can always login to the platform and either make a choice about what context they are working in at that time, or be sent to the default tenancy they normally work in, or last worked in, etc.. +* This also means that when they register on the central platform they are automatically assigned to their own "personal" tenant (`Organization`), from which they can use the product (to some degree - depending on their subscription at that time). They must always have that personal `Organization`, for the times when they lose access to any other tenancy (i.e., a consultant ends their engagement with a company, or as an employee change jobs at any organization). + +> Of course, it should remain possible to change that default tenant/organization at any time, and certain actions, like getting invited to another tenancy can automatically change that default also. + +This all implies the following domain model that both describes multi-tenancy and identity, would look like this: + +![Multitenancy](../images/Multitenancy.png) + +As you can see, `EndUser` is the at the heart of the model, and almost everything else is dependent upon it. The membership model decides access to one or more `Organizations`, and each organization references a `Subscription` determining their access to the rest of the product. + +### Managing Multi-tenancy + +Lets break down the implementation of multi-tenancy in SaaStack into these critical areas: + +* [Creation of tenants](#Tenant-Creation) +* [Configuration for each tenant](#Infrastructure-Configuration) +* [Detection of a tenant](#Tenant-Detection) +* [Storage of data](#Data-Storage) + +#### Tenant Creation + +In systems that host multiple tenants, the process of creating and provisioning new tenants typically occurs automatically. + +Depending on the infrastructure required for each tenant, this process may be manual or automated, and the time it takes to complete may vary. + +For example, provisioning a new database in a different geographic region could take longer than creating a new record in a shared database in a shared region. + +To illustrate, in some products, buyers may register their account on a customer management site, and after a short delay, a new tenancy is set up on the main product website. As this process can be costly, it may require some account management by the SaaS business. + +On the other hand, for another product, buyers can sign up on the main application website, and their account is created in the central storage tenant instantly without any additional account management or delays. + +In SaaStack, a tenant is initially modeled as an `Organization`. + +> The "Organization" subdomain can be renamed to be any of these concepts, to fit the specific business model of the SaaS business: `Group`, `Company`, `Workspace`, `Project`, `Part` or `Team`, etc. + +When a new ~~tenant~~ `Organisation` is created (via the API), it is created in a centralized (and untenanted) part of the system, where all `EndUser` and `Memberships` are also created. This means that `Organizations` are global across the entire product. + +The data/record about the `Organisation` is created instantly. At the same time, the record is populated with any settings pertinent to that tenant. (see Configuration section below). + +If any infrastructure is required to be provisioned and configured, that process (automated or manual) can be triggered by registering a listener to events from the `Organization` events (via Notifications), and by responding to the `OrganizationsDomain.Events.Created` event. + +> Further, if provisioning physical infrastructure is expected to take some time (i.e., greater 1sec) then code (API's plus domain code) can be added to the `Organization` subdomain to include a process in the lifecycle of an `Organization` that checks on the completeness of the provisioning process before certain other actions can be performed with the organization. + +#### Infrastructure Configuration + +In multi-tenanted SaaS systems, we always share computing resources across multiple tenants, whether that is on a single server/process or across multiple scaled-out servers/processes. + +However, sometimes we need (due to the our business model) to separate/segregate customer data to different locations or different ownership, e.g., different databases in different global data centers, or a dedicated databases per tenant. + +To do that, each tenant requires its own unique configuration for connecting to those services (e.g., the technology-specific `IDataStore` adapter, like the `SqlServerDataStore`, or the `DynamoDbDataStore`). + +> SaaStack uses a ports and adapters architecture where the adapters have all the knowledge (and read their own configuration) for connecting to remote 3rd party services. This is true for data storage adapters in the same way it is true for any 3rd party adapters to any external service. + +Runtime configuration is considered in two parts: + +1. `Platform` configuration settings, that are shared by all infrastructure across the platform. (usually stored in static files like `appsettings.json`) +2. `Tenancy` configuration settings, that are private and specific to a specific tenant. (stored in a data store, in the `Organization` subdomain) + +Therefore, for multi-tenancy to work at runtime, some adapters in the code must load configuration settings that are specific to each tenant the code accesses (on behalf of that tenant). To enable that to happen, each `Organization` has its own set of `TenantSettings` that it can use to configure these adapters at runtime. + +This needs to happen dynamically at runtime to work effectively, and it works at runtime like this: + +1. When an HTTP request comes into the Backend API, a new request "scope" is created for each request by the dependency injection container, so that we can resolve "scoped" dependencies (as well as "transient" dependencies). Note: "singleton" dependencies will have already been resolved at this point in time. +2. The request is processed by the middleware pipeline, and in the middleware pipeline we have the `MultiTenancyMiddleware` that parses the HTTP request and uses the `ITenantDetective` (see detection below) to discover the specific tenant of the inbound request. Once discovered, it sets the `ITenancyContext.Current` with the tenant ID and sets the `ITenancyContext.Settings` for that specific tenant. These settings are fetched from the stored `Organization` that the tenant identifies, which were created when the `Organization` was created. +3. Then, later down the request pipeline, a new instance of an "Application Layer" class (e.g., the `ICarsApplication`), and this application class is injected into the `CarsApi` class, where the request is processed by the remaining code. This application class instance will require a dependency on one or more technology adapters to 3rd party services (e.g., the `ICarRepository`) that will ultimately be dependent on an adapter for the `IDataStore` that can be satisfied by instantiating some technology adapter (e.g., `SqlServerDataStore` or `DynamoDbDataStore`). Into the constructor of this instance of technology adapter, an instance of the `IConfigurationSettings` will be injected, which will ultimately be dependent on the `ITenancyContext.Settings` which, in turn, will fetch specific settings for this specific tenant. (see the `AspNetConfigurationSettings`) +4. Finally, the specific technology adapter (e.g. `SqlServerDataStore` or `DynamoDbDataStore`) will load its configuration settings, the `IConfigurationSettings` will attempt to retrieve them first, from the current `ITenancyContext.Settings` if they exist for the tenant, and if not, then retrieve them from the shared `Platform` settings (available to all tenants). + +Ultimately, the actual settings that are used in any adapter in any Application or Domain Layer, is down to two things: + +1. Whether the port is registered in the DI container as "scoped", "transient" or "singleton" +2. If "singleton", then only shared `Platform` settings are available to it. +3. If "scoped" AND if the actual HTTP request belongs to a specific tenant, AND if the specific `Organization.Settings` contains the required setting, then it will be used, otherwise the shared `Platform` setting will be used. +4. If "transient", it is similar to "scoped" above, except that the instance of the adapter is recreated each time it is resolved in the container, rather than being reused for the lifetime of the current HTTP request, which may cause other issues. + +##### A note about dependency injection + +For many "generic" subdomains like `EndUsers`, `Organizations`, `Identity` and `Ancillary` etc all their data will be centralized (by default), and none of that data is specific to any tenant. They store the data that all tenants require to operate, including the definition of the tenants themselves (`Organizations`). These dependencies have the lifetime of "singletons" (i.e. one instance shared with all consumers, and only ever instantiated once). They also only use `Platform` configuration settings. + +However, most of the "core" subdomains like `Cars`, `Bookings` (and the others to be added) are likely to be tenant-specific. These dependencies have a lifetime of "scoped" (i.e. a different lifetime per HTTP request). They may require private tenant specific configuration settings to work, or may use shared `Platform` configuration settings. + +> It is NOT really common to use "transient" lifetime for the adapter dependencies of subdomains. Even though, technically that would work - it would just be inefficient in memory, and for some technology adapters a waste of resources (i.e. caches). However, some stateless dependencies could be "transient". + +This means that at runtime, there will always be some services that are tenant specific, and some that will be not-specific specific, and some (like `IDataStore`) that are registered in the container, for both, at the same time (one instance for use by tenanted dependents, one for use by non-tenanted dependents). + +> Note: Great care must be taken when configuring the dependency injection for every domain in the software, so that infrastructure that should be tenant specific is not accidentally configured to use centralized infrastructure. +> +> Also, make sure you don't inject "transient" dependencies into "scoped" or "singleton" dependencies, and don't inject "scoped" dependencies into "singleton" dependencies". Neither of these things have the desired effect with lifetimes. + +One last point about dependency injection and multi-tenancy that is quite important: + +* The MediatR handlers that are used in the API layer to relay requests to the Application Layer are required to be registered in the container as either "transient" or "scoped". This is important, so that when ever a "scoped" dependency is resolved in the handler, it is resolved in the same "scope" of the HTTP request. + +(see [Dependency Injection](0060-dependency-injection.md) for more details) + +#### Tenant Detection + +Every end user of the system needs to belong to at least one tenancy to use the product meaningfully in the "core" domains. Otherwise, they would have limited abilities restricted to only some of the "generic" subdomains. + +> Note: There are exceptions to this rule for a limited set of special service accounts in any product, that does not belong to a specific tenant. For example, the anonymous account, and other background worker processes that operate on data for all tenants. (e.g. asynchronous queuing services, or clean-up services). + +Once a user identifies with and interacts with the product/system, their tenancy needs to be known immediately, and this tenancy needs to be passed throughout the technical system so that each process in the system can know and honor their tenant. + +For that to be possible, in the broadest range of options, the tenants and the users need to be held centrally to be looked up at runtime. + +> Note: Some SaaS systems store users in each of the tenants, which is a viable solution only if the tenant can be pre-determined AND users are duplicated across the tenants that they are members of. In SaaStack the default is to keep all `EndUsers` centrally, so that a end user in the software equates to a single email address in the real world, and end users can be members in many tenancies, and can come and go frequently from those tenancies. + +In some systems, the user's tenancy is pre-determined for them ahead of interacting with the system (each request contains that information); in some systems, the tenancy that they want to access needs to be manually selected in some way and then declared to the system when interacting with it. + +> Note: In SaaStack, the default is to identify the tenancy in the request coming into the API, or once authenticated, use the ID of the default tenant (Organization) that is stored against that specific authenticated user. Anonymous users would then need to log in against a central login mechanism to determine the default tenant for that user, once authenticated. + +There are several common ways to declare a tenancy in any HTTP request: + +* In a call to a WebApp or API, using host names (or host headers). + * e.g., if they access `https://tenant1.acme.com/login.html` + * Their tenant is identified beforehand as `tenant1`. In this scheme, users may be able to access other tenants, but it would have to be through different URLs, or they access a central URL (like: `www.acme.com`) and make a selection of a tenant there. + +* In a call to a WebApp or API, using URI paths . + * e.g., if they access `https://www.acme.com/tenant1/login.html` + * Their tenant is identified as: `tenant1`. Same caveats as above. + +* In a call to a WebApp or API, using API keys in headers or the query string. + * e.g., if they access `https://www.acme.com/login.html` and in the request is an `ApiKeyToken` header containing the value of `tenant1` + * Their tenant is identified as: `tenant1`. Similar caveats as above. + +* In a call to an API only, using specific claims in an *access_token*. + * The caveat is that with an anonymous user, the tenant cannot be identified. Also, in systems where the memberships can change, the *access_token* will need to be forcibly refreshed - which leads to other downstream problems + +* Finally, In a call to a WebApp only, if they access `https://www.acme.com/login.html` and are prompted for their username, and since they are not authenticated yet, their username can be looked-up in the central directory of memberships, and their tenant or tenants can be listed for them to choose manually, or select their default tenancy (if they have one). + +In every case, no matter how the tenant is communicated in each HTTP request, if the user is authenticated at that time, their membership to that tenant must be validated against the tenant. + +Once the request is verified, the `TenantId` can be passed downstream to other processes, via dependency injected services. + +> Typically, this can be achieved by including the `TenantId` in downstream HTTP requests, and in events or messages send across messaging infrastructure. + +In SaaStack, we use three components to figure-out and expose the tenant for every incoming request. + +1. We use the `MultiTenancyMiddleware` in the request pipeline to begin the process. This middleware needs to run before the authorization middleware runs since the authorization middleware requires knowledge of the current tenant to work properly in cases where tenancy roles are required. +2. The `MultiTenancyMiddleware` parses the request and uses the `ITenantDetective` to extract the ID of the tenant from the HTTP request. This + *detective* is the only component that has knowledge of where the tenant is identified in the HTTP request. Once the ID is found in the request (or not), the middleware continues. If the ID of the tenant is not found, and if the user is authenticated, then the default organization of the user is used. Otherwise, the process fails. If the ID of the tenant is found by the detective, it is then validated, and if authenticated verified against the memberships of the user. Finally, the middleware sets the ID of the tenant and the settings for that tenant to the `ITenancyContext`, which is used further down the request pipeline. +3. The final component in this process is the `MultiTenancyFilter` which is active on any API endpoint that uses an `IWebRequest` that is also an `ITenantedRequest`. This filter gives us access to the "request delegate" of the minimal API declaration, which includes the original request as a typed parameter, which is then unpacked and fed downstream to the Application Layer. Those typed requests (`ITenantedRequest`) will have an optional `OrganizationId` in either their request body (in the case of POST, PUT, or PATCH requests) or in their query parameters (in the case of GET requests), that is passed from the request pipeline to the Application Layer. If that `OrganizationId` is not present in the body or the query parameters of the original request (by the API caller), AND if a tenant ID is derived by the middleware (e.g., from an authenticated user), then that derived tenant ID is now written into the `OrganizationId` field of the endpoint request delegate (`ITenantedRequest.OrganizationId`). + +With the request pipeline processing above, it is possible to detect the tenant (or derive a sensible default for it) and pass that downstream to the Application Layer, as you might expect. + +#### Data Storage + +It is common for any given SaaS product to use multiple data stores and third-party services, as well as different technologies (e.g., a mix of relational databases and non-relational databases). Each store is typically dedicated to a specific kind of data and workload, such as caching data in in-memory databases, transactional application state in SQL or NoSQL databases, logging data in files or binary stores, reporting data in warehouses, and audit data in write-only storage, to name a few. + +> Today, there are many options that are better optimized for the data type rather than storing all data in a single relational database, which used to be the only approach and is still common in many systems. + +For instance, in SaaStack today, by default, we already use data sources like: + +1. Azure Storage Account Queues or SQS queues for delivering emails to an email provider (like SendGrid, MailGun or PostMark) +2. Azure Storage Account Queues or SQS queues for relaying audits to permanent storage in a queryable database like SQLServer. +3. Azure Storage Account Blobs or S3 buckets for storing uploaded files and pictures. +4. Memcached or Redis Server for caching data in memory. +5. Azure SQL Server, DynamoDB, or EventStore DB for storing subdomain state. + +As SaaS products evolve, we would expect then to use more third-party services, such as Chargebee and/or Stripe for billing account management, Auth0 or Okta or Cognito for identity services, and Twilio for text messaging, among others. + +With all these different services and all these different workloads in the product, we need a strategy for separating the data for each of these services per tenant, either logically or physically, and drive that with configuration to access their respective remote infrastructure. + +##### Logical Data Partitioning + +Logical data partitioning is a technique used to store data from multiple tenants in a single physical repository or service. + +This approach is supported by most data stores and third-party online services in one form or another. However, not all online services implement it very well. + +The key concept behind logical data partitioning is to utilize a single physical service (and usually account subscription) and specify a "partition" for each tenant in the data. + +For example, in a SQL database, you can add an additional foreign key column to each table that contains tenanted data called `TenantId` and ensure that this column participates in each and every SQL operation, such as SELECT, INSERT, UPDATE, and DELETE. + +> Whereas, shared data across all tenants will not require this constraint. +> +> There is a real world danger with this kind of partitioning, that developers can easily make a mistake and forget to include the `TenantId` in the query or update statement, and they can inadvertently expose data from one tenant to others, causing a data breach event. + +For example, in Azure Table Storage, you might define the `PartitionKey` as the `TenantId`, and create a combined `PartitionKey` with the `TenantId` and the `EntityId`, or create a combined `RowKey` using the `TenantId` for each table. + +For example, in a call to a third-party service that is already multi-tenanted, like www.Chargebee.com, and requires unique API keys per tenant, you might include an identifier in the metadata of the API call to identify the tenant. + +For example, in a call to a third-party service that is not natively multi-tenanted, like www.sendgrid.com and uses a "shared" API key, regardless of the tenant in the application, you might include an identifier in the metadata of the API call to identify the tenant to help with reading the data back for a specific tenant in the application. + +Logical data partitioning is most often controlled by the product software itself, and it relies on the product team to ensure that all software applies, honors and abides by this partitioning. However, it does not guarantee that information disclosure across partitions is not an accidental possibility. + +> Past defects in the software and mistakes and shortcuts in DevOps practices have resulted in numerous accidental cross-tenancy information disclosures and privacy data breaches with severe consequences. + +Data partitioning is on the other hand cheaper to build and maintain since it uses shared physical infrastructure components (i.e. one database). However, as SaaS products scale out and move globally, other factors become more significant to the business. + +Not all single data repositories and third-party services can address those needs effectively when relying on logical data partitioning. + +##### Infrastructure Partitioning + +Infrastructure partitioning is similar to logical partitioning except that it uses separate (but similar) physical infrastructure to contain the data of each tenant. That could be a separate server, database, storage account, which is addressed differently than other partitions, sometimes in different physical locations or data centers. + +It is becoming increasingly financially viable for small companies to deploy this capability, thanks to the large cloud providers, who make it more economical and easier to perform. + +Each tenant is assigned its own dedicated physical infrastructure for exclusive use. This infrastructure can be set up and taken down as tenants onboard and exit the SaaS platform. + +In some cases, physical ownership of the actual infrastructure and rights are put in place to protect data even after the subscription ends. + +In some cases, tenants do BYO of their own infrastructure to be used by the SaaS product. + +In some cases, tenants may also manage their own dedicated subscriptions to third-party services like stripe.com or chargebee.com. + +Infrastructure partitioning may be mandatory for SaaS products with specific compliance needs, such as HIPAA, PCI, or government, where shared data stores are not permitted or where they have expensive compliance and access requirements. + +Physical partitioning has many other benefits, including a reduced risk of accidental information disclosure since access to these dedicated resources is more difficult to execute accidentally in code or during production support, maintenance, and administrative processes. + +Dedicated partitioned infrastructure can also be deployed closer to the tenant's region or onto their premises, which is impossible with logical data partitioned infrastructure. + +Network latency can also be reduced by deploying dedicated infrastructure closer to the tenant, whereas data partitioned infrastructure is often shared from one or more physical locations that may not be physically close to the product consumers. + +Finally, managing the cost of physical resources per tenant can be more carefully controlled and optimized by the buyer, thanks to mature cloud provider tools and services. + +At some point, many SaaS products will need to explore infrastructure partitioning with certain customers, usually the larger or more strategic customers who are likely to have special needs. + +### Provisioning + +For SaaS products that wish to provision physical infrastructure to implement "Infrastructure Partitioning" for one or more tenants here are some options. + +Consider the following workflow: + +1. A new customer signs up for the platform. They register a new user, and that will create a new `Personal` organization for them to use the product. This organization will have a billing subscription that gives them some [limited] access level to the product at this time (i.e., a trial). +2. At that time, or at some future time (like when they upgrade to a paid plan) a new event (e.g., `EndUsersDomain.Events.Registered`) can be subscribed to by adding a new `IEventNotificationRegistration` in one of the subdomains. +3. This event is then raised at runtime, which triggers an application (in some subdomain) to make some API call to some cloud-based process to provision some specific infrastructure (e.g., via queue message or direct via an API call to an Azure function or AWS Lambda - there are many integration options). Let's assume that this triggers Azure to create a new SQL database in a regional data center physically closer to where this specific customer is signing up from. +4. Let's assume that this cloud provisioning process takes some time to complete (perhaps several minutes), and meanwhile, the customer is starting using the product and try it out for themselves (using their `Personal` organisation, which we assume is using shared platform infrastructure at this time. +5. When the provisioning process is completed (a few minutes later), a new message [containing some data about the provisioning process] is created and dropped on the `provisioning` queue (in Azure or AWS). +6. The `DeliverProvisioning` task is triggered, and the message is picked up off the queue, and delivered to the `Ancillary` API by the Azure function or AWS Lambda. +7. The `Ancillary` API then handles the message and forwards it to the `Organization` subdomain to update the settings of the `Personal` organization that the customer is using. +8. As soon as that happens, if we assume that the message contained a connection string to another SQL database, then the very next HTTP request made by the customer will start to persist data to a newly provisioned database. +9. From that point forward the newly provisioned database stores all tenanted core subdomain data in the newly provisioned database. + +The `provisioning` queue is already in place, and so is all handling of messages for that queue, all the way to updating the settings of a spefici `Organization`. + +All that is needed now is: + +1. A scripted provisioning process to be defined in some cloud provider. That could be via executing a script that automates the provisioning, or could be an API call direct to the cloud provider with some script already defined in the cloud provider. +2. Some way to trigger the provisioning process itself, based upon some event in the software. Be that a new customer signup or some action they take. That could be `IEventNotificationRegistration` or another mechanism. +3. A way for the provisioning script to construct and deposit a message (in the form of a `ProvisioningMessage`) and deposit it on the `provisioning` queue. diff --git a/docs/images/Multitenancy.png b/docs/images/Multitenancy.png new file mode 100644 index 00000000..58bd251a Binary files /dev/null and b/docs/images/Multitenancy.png differ diff --git a/docs/images/Sources.pptx b/docs/images/Sources.pptx index 09afbd57..f7cb40bf 100644 Binary files a/docs/images/Sources.pptx and b/docs/images/Sources.pptx differ diff --git a/src/AWSLambdas.Api.WorkerHost/HostExtensions.cs b/src/AWSLambdas.Api.WorkerHost/HostExtensions.cs index 1635825f..27c8415d 100644 --- a/src/AWSLambdas.Api.WorkerHost/HostExtensions.cs +++ b/src/AWSLambdas.Api.WorkerHost/HostExtensions.cs @@ -23,7 +23,7 @@ public static class HostExtensions public static void AddDependencies(this IServiceCollection services, IConfiguration configuration) { services.AddHttpClient(); - services.AddSingleton(new AspNetConfigurationSettings(configuration)); + services.AddSingleton(new AspNetDynamicConfigurationSettings(configuration)); services.AddSingleton(); services.AddSingleton(); @@ -46,5 +46,6 @@ public static void AddDependencies(this IServiceCollection services, IConfigurat services.AddSingleton, DeliverUsageRelayWorker>(); services.AddSingleton, DeliverAuditRelayWorker>(); services.AddSingleton, DeliverEmailRelayWorker>(); + services.AddSingleton, DeliverProvisioningRelayWorker>(); } } \ No newline at end of file diff --git a/src/AWSLambdas.Api.WorkerHost/Lambdas/DeliverProvisioning.cs b/src/AWSLambdas.Api.WorkerHost/Lambdas/DeliverProvisioning.cs new file mode 100644 index 00000000..a2763277 --- /dev/null +++ b/src/AWSLambdas.Api.WorkerHost/Lambdas/DeliverProvisioning.cs @@ -0,0 +1,24 @@ +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Core; +using Amazon.Lambda.SQSEvents; +using Application.Persistence.Shared.ReadModels; +using AWSLambdas.Api.WorkerHost.Extensions; +using Infrastructure.Workers.Api; + +namespace AWSLambdas.Api.WorkerHost.Lambdas; + +public class DeliverProvisioning +{ + private readonly IQueueMonitoringApiRelayWorker _worker; + + public DeliverProvisioning(IQueueMonitoringApiRelayWorker worker) + { + _worker = worker; + } + + [LambdaFunction] + public async Task Run(SQSEvent sqsEvent, ILambdaContext context) + { + return await sqsEvent.RelayRecordsAsync(_worker, CancellationToken.None); + } +} \ No newline at end of file diff --git a/src/AWSLambdas.Api.WorkerHost/serverless.template b/src/AWSLambdas.Api.WorkerHost/serverless.template index edd49ab4..35ddfefd 100644 --- a/src/AWSLambdas.Api.WorkerHost/serverless.template +++ b/src/AWSLambdas.Api.WorkerHost/serverless.template @@ -53,6 +53,23 @@ "PackageType": "Zip", "Handler": "AWSLambdas.Api.WorkerHost::AWSLambdas.Api.WorkerHost.Lambdas.DeliverEmail_Run_Generated::Run" } + }, + "AWSLambdasApiWorkerHostLambdasDeliverProvisioningRunGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "Runtime": "dotnet6", + "CodeUri": ".", + "MemorySize": 256, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Zip", + "Handler": "AWSLambdas.Api.WorkerHost::AWSLambdas.Api.WorkerHost.Lambdas.DeliverProvisioning_Run_Generated::Run" + } } } } \ No newline at end of file diff --git a/src/AncillaryApplication.UnitTests/AncillaryApplicationSpec.cs b/src/AncillaryApplication.UnitTests/AncillaryApplicationSpec.cs index e28fb89b..04b2f1c3 100644 --- a/src/AncillaryApplication.UnitTests/AncillaryApplicationSpec.cs +++ b/src/AncillaryApplication.UnitTests/AncillaryApplicationSpec.cs @@ -2,6 +2,7 @@ using AncillaryApplication.Persistence.ReadModels; using AncillaryDomain; using Application.Interfaces; +using Application.Interfaces.Services; using Application.Persistence.Shared; using Application.Persistence.Shared.ReadModels; using Common; @@ -30,6 +31,8 @@ public class AncillaryApplicationSpec private readonly Mock _emailDeliveryService; private readonly Mock _emailMessageQueue; private readonly Mock _idFactory; + private readonly Mock _provisioningDeliveryService; + private readonly Mock _provisioningMessageQueue; private readonly Mock _recorder; private readonly Mock _usageDeliveryService; private readonly Mock _usageMessageQueue; @@ -46,25 +49,30 @@ public AncillaryApplicationSpec() _auditMessageRepository = new Mock(); _auditRepository = new Mock(); _auditRepository.Setup(ar => ar.SaveAsync(It.IsAny(), It.IsAny())) - .Returns((AuditRoot root, CancellationToken _) => Task.FromResult>(root)); + .ReturnsAsync((AuditRoot root, CancellationToken _) => root); _emailMessageQueue = new Mock(); _emailDeliveryService = new Mock(); _emailDeliveryService.Setup(eds => eds.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult>(new EmailDeliveryReceipt())); + .ReturnsAsync(new EmailDeliveryReceipt()); _emailDeliveryRepository = new Mock(); _emailDeliveryRepository.Setup(ar => ar.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns((EmailDeliveryRoot root, bool _, CancellationToken _) => - Task.FromResult>(root)); + .ReturnsAsync((EmailDeliveryRoot root, bool _, CancellationToken _) => root); _emailDeliveryRepository.Setup(edr => edr.FindDeliveryByMessageIdAsync(It.IsAny(), It.IsAny())) - .Returns(Task.FromResult, Error>>(Optional.None)); + .ReturnsAsync(Optional.None); + _provisioningMessageQueue = new Mock(); + _provisioningDeliveryService = new Mock(); + _provisioningDeliveryService.Setup(pds => pds.DeliverAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Ok); _application = new AncillaryApplication(_recorder.Object, _idFactory.Object, _usageMessageQueue.Object, _usageDeliveryService.Object, _auditMessageRepository.Object, _auditRepository.Object, - _emailMessageQueue.Object, _emailDeliveryService.Object, _emailDeliveryRepository.Object); + _emailMessageQueue.Object, _emailDeliveryService.Object, _emailDeliveryRepository.Object, + _provisioningMessageQueue.Object, _provisioningDeliveryService.Object); } [Fact] @@ -91,7 +99,7 @@ public async Task WhenDeliverUsageAsyncAndMessageHasNoForId_ThenReturnsError() var result = await _application.DeliverUsageAsync(_caller.Object, messageAsJson, CancellationToken.None); result.Should().BeError(ErrorCode.RuleViolation, - Resources.AncillaryApplication_MissingUsageForId); + Resources.AncillaryApplication_Usage_MissingForId); _usageDeliveryService.Verify( urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); @@ -109,7 +117,7 @@ public async Task WhenDeliverUsageAsyncAndMessageHasNoEventName_ThenReturnsError var result = await _application.DeliverUsageAsync(_caller.Object, messageAsJson, CancellationToken.None); result.Should().BeError(ErrorCode.RuleViolation, - Resources.AncillaryApplication_MissingUsageEventName); + Resources.AncillaryApplication_Usage_MissingEventName); _usageDeliveryService.Verify( urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); @@ -161,7 +169,7 @@ public async Task WhenDeliverAuditAsyncAndMessageHasNoAuditCode_ThenReturnsError var result = await _application.DeliverAuditAsync(_caller.Object, messageAsJson, CancellationToken.None); result.Should().BeError(ErrorCode.RuleViolation, - Resources.AncillaryApplication_MissingAuditCode); + Resources.AncillaryApplication_Audit_MissingCode); _auditRepository.Verify( ar => ar.SaveAsync(It.IsAny(), It.IsAny()), Times.Never); } @@ -217,7 +225,7 @@ public async Task WhenDeliverEmailAsyncAndMessageHasNoHtml_ThenReturnsError() var result = await _application.DeliverEmailAsync(_caller.Object, messageAsJson, CancellationToken.None); result.Should().BeError(ErrorCode.RuleViolation, - Resources.AncillaryApplication_MissingEmailHtml); + Resources.AncillaryApplication_Email_MissingHtml); _emailDeliveryService.Verify( urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), @@ -346,6 +354,69 @@ public async Task WhenDeliverEmailAsyncAndAlreadyDelivered_ThenDoesNotRedeliver( ), true, It.IsAny())); } + [Fact] + public async Task WhenDeliverProvisioningAsyncAndMessageIsNotRehydratable_ThenReturnsError() + { + var result = + await _application.DeliverProvisioningAsync(_caller.Object, "anunknownmessage", CancellationToken.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.AncillaryApplication_InvalidQueuedMessage.Format(nameof(ProvisioningMessage), + "anunknownmessage")); + _provisioningDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenDeliverProvisioningAsyncAndMessageHasNoTenantId_ThenReturnsError() + { + var messageAsJson = new ProvisioningMessage + { + TenantId = null, + Settings = new Dictionary + { + { "aname", new TenantSetting("avalue") } + } + }.ToJson()!; + + var result = await _application.DeliverProvisioningAsync(_caller.Object, messageAsJson, CancellationToken.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.AncillaryApplication_Provisioning_MissingTenantId); + _provisioningDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenDeliverProvisioningAsync_ThenDelivers() + { + var messageAsJson = new ProvisioningMessage + { + TenantId = "atenantid", + Settings = new Dictionary + { + { "aname1", new TenantSetting("avalue") }, + { "aname2", new TenantSetting(99) }, + { "aname3", new TenantSetting(true) } + } + }.ToJson()!; + + var result = await _application.DeliverProvisioningAsync(_caller.Object, messageAsJson, CancellationToken.None); + + result.Should().BeSuccess(); + _provisioningDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), "atenantid", + It.Is(dic => + dic.Count == 3 + && dic["aname1"].As().Value.As() == "avalue" + // ReSharper disable once CompareOfFloatsByEqualityOperator + && dic["aname2"].As().Value.As() == 99D + && dic["aname3"].As().Value.As() == true + ), It.IsAny())); + } + [Fact] public async Task WhenSearchAllDeliveredEmails_ThenReturnsEmails() { @@ -619,5 +690,85 @@ public async Task WhenDrainAllEmailsAsyncAndSomeOnQueue_ThenDeliversAll() It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); } + + [Fact] + public async Task WhenDrainAllProvisioningsAsyncAndNoneOnQueue_ThenDoesNotDeliver() + { + _provisioningMessageQueue.Setup(umr => + umr.PopSingleAsync(It.IsAny>>>(), + It.IsAny())) + .Returns(Task.FromResult>(false)); + + var result = await _application.DrainAllProvisioningsAsync(_caller.Object, CancellationToken.None); + + result.Should().BeSuccess(); + _provisioningMessageQueue.Verify( + urs => urs.PopSingleAsync(It.IsAny>>>(), + It.IsAny())); + _provisioningDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenDrainAllProvisioningsAsyncAndSomeOnQueue_ThenDeliversAll() + { + var message1 = new ProvisioningMessage + { + TenantId = "atenantid1", + Settings = new Dictionary + { + { "aname", new TenantSetting("avalue1") } + } + }; + var message2 = new ProvisioningMessage + { + TenantId = "atenantid2", + Settings = new Dictionary + { + { "aname", new TenantSetting("avalue2") } + } + }; + var callbackCount = 1; + _provisioningMessageQueue.Setup(umr => + umr.PopSingleAsync(It.IsAny>>>(), + It.IsAny())) + .Callback((Func>> action, CancellationToken _) => + { + if (callbackCount == 1) + { + action(message1, CancellationToken.None); + } + + if (callbackCount == 2) + { + action(message2, CancellationToken.None); + } + }) + .Returns((Func>> _, CancellationToken _) => + { + callbackCount++; + return Task.FromResult>(callbackCount is 1 or 2); + }); + + var result = await _application.DrainAllProvisioningsAsync(_caller.Object, CancellationToken.None); + + result.Should().BeSuccess(); + _provisioningMessageQueue.Verify( + urs => urs.PopSingleAsync(It.IsAny>>>(), + It.IsAny()), Times.Exactly(2)); + _provisioningDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), "atenantid1", + It.Is(dic => dic["aname"].Value.As() == "avalue1"), + It.IsAny())); + _provisioningDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), "atenantid2", + It.Is(dic => dic["aname"].Value.As() == "avalue2"), + It.IsAny())); + _provisioningDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + #endif } \ No newline at end of file diff --git a/src/AncillaryApplication/AncillaryApplication.cs b/src/AncillaryApplication/AncillaryApplication.cs index 09c3ea39..95bb0b3c 100644 --- a/src/AncillaryApplication/AncillaryApplication.cs +++ b/src/AncillaryApplication/AncillaryApplication.cs @@ -1,8 +1,10 @@ +using System.Text.Json; using AncillaryApplication.Persistence; using AncillaryApplication.Persistence.ReadModels; using AncillaryDomain; using Application.Common.Extensions; using Application.Interfaces; +using Application.Interfaces.Services; using Application.Persistence.Interfaces; using Application.Persistence.Shared; using Application.Persistence.Shared.ReadModels; @@ -24,16 +26,19 @@ public class AncillaryApplication : IAncillaryApplication private readonly IIdentifierFactory _idFactory; private readonly IRecorder _recorder; private readonly IUsageDeliveryService _usageDeliveryService; + private readonly IProvisioningDeliveryService _provisioningDeliveryService; #if TESTINGONLY private readonly IAuditMessageQueueRepository _auditMessageQueueRepository; private readonly IEmailMessageQueue _emailMessageQueue; private readonly IUsageMessageQueue _usageMessageQueue; + private readonly IProvisioningMessageQueue _provisioningMessageQueue; public AncillaryApplication(IRecorder recorder, IIdentifierFactory idFactory, IUsageMessageQueue usageMessageQueue, IUsageDeliveryService usageDeliveryService, IAuditMessageQueueRepository auditMessageQueueRepository, IAuditRepository auditRepository, IEmailMessageQueue emailMessageQueue, IEmailDeliveryService emailDeliveryService, - IEmailDeliveryRepository emailDeliveryRepository) + IEmailDeliveryRepository emailDeliveryRepository, + IProvisioningMessageQueue provisioningMessageQueue, IProvisioningDeliveryService provisioningDeliveryService) { _recorder = recorder; _idFactory = idFactory; @@ -44,6 +49,8 @@ public AncillaryApplication(IRecorder recorder, IIdentifierFactory idFactory, _emailMessageQueue = emailMessageQueue; _emailDeliveryService = emailDeliveryService; _emailDeliveryRepository = emailDeliveryRepository; + _provisioningMessageQueue = provisioningMessageQueue; + _provisioningDeliveryService = provisioningDeliveryService; } #else public AncillaryApplication(IRecorder recorder, IIdentifierFactory idFactory, @@ -53,7 +60,9 @@ public AncillaryApplication(IRecorder recorder, IIdentifierFactory idFactory, IAuditMessageQueueRepository auditMessageQueueRepository, IAuditRepository auditRepository, // ReSharper disable once UnusedParameter.Local IEmailMessageQueue emailMessageQueue, IEmailDeliveryService emailDeliveryService, - IEmailDeliveryRepository emailDeliveryRepository) + IEmailDeliveryRepository emailDeliveryRepository, + // ReSharper disable once UnusedParameter.Local + IProvisioningMessageQueue provisioningMessageQueue, IProvisioningDeliveryService provisioningDeliveryService) { _recorder = recorder; _idFactory = idFactory; @@ -61,6 +70,7 @@ public AncillaryApplication(IRecorder recorder, IIdentifierFactory idFactory, _auditRepository = auditRepository; _emailDeliveryService = emailDeliveryService; _emailDeliveryRepository = emailDeliveryRepository; + _provisioningDeliveryService = provisioningDeliveryService; } #endif @@ -73,7 +83,7 @@ public async Task> DeliverEmailAsync(ICallerContext context, return rehydrated.Error; } - var delivered = await DeliverEmailAsync(context, rehydrated.Value, cancellationToken); + var delivered = await DeliverEmailInternalAsync(context, rehydrated.Value, cancellationToken); if (!delivered.IsSuccessful) { return delivered.Error; @@ -83,6 +93,25 @@ public async Task> DeliverEmailAsync(ICallerContext context, return true; } + public async Task> DeliverProvisioningAsync(ICallerContext context, string messageAsJson, + CancellationToken cancellationToken) + { + var rehydrated = RehydrateMessage(messageAsJson); + if (!rehydrated.IsSuccessful) + { + return rehydrated.Error; + } + + var delivered = await DeliverProvisioningInternalAsync(context, rehydrated.Value, cancellationToken); + if (!delivered.IsSuccessful) + { + return delivered.Error; + } + + _recorder.TraceInformation(context.ToCall(), "Delivered provisioning message: {Message}", messageAsJson); + return true; + } + public async Task> DeliverUsageAsync(ICallerContext context, string messageAsJson, CancellationToken cancellationToken) { @@ -92,7 +121,7 @@ public async Task> DeliverUsageAsync(ICallerContext context, return rehydrated.Error; } - var delivered = await DeliverUsageAsync(context, rehydrated.Value, cancellationToken); + var delivered = await DeliverUsageInternalAsync(context, rehydrated.Value, cancellationToken); if (!delivered.IsSuccessful) { return delivered.Error; @@ -111,7 +140,7 @@ public async Task> DeliverAuditAsync(ICallerContext context, return rehydrated.Error; } - var delivered = await DeliverAuditAsync(context, rehydrated.Value, cancellationToken); + var delivered = await DeliverAuditInternalAsync(context, rehydrated.Value, cancellationToken); if (!delivered.IsSuccessful) { return delivered.Error; @@ -125,7 +154,7 @@ public async Task> DeliverAuditAsync(ICallerContext context, public async Task> DrainAllEmailsAsync(ICallerContext context, CancellationToken cancellationToken) { await DrainAllAsync(_emailMessageQueue, - message => DeliverEmailAsync(context, message, cancellationToken), cancellationToken); + message => DeliverEmailInternalAsync(context, message, cancellationToken), cancellationToken); _recorder.TraceInformation(context.ToCall(), "Drained all email messages"); @@ -133,11 +162,24 @@ await DrainAllAsync(_emailMessageQueue, } #endif +#if TESTINGONLY + public async Task> DrainAllProvisioningsAsync(ICallerContext context, + CancellationToken cancellationToken) + { + await DrainAllAsync(_provisioningMessageQueue, + message => DeliverProvisioningInternalAsync(context, message, cancellationToken), cancellationToken); + + _recorder.TraceInformation(context.ToCall(), "Drained all provisioning messages"); + + return Result.Ok; + } +#endif + #if TESTINGONLY public async Task> DrainAllUsagesAsync(ICallerContext context, CancellationToken cancellationToken) { await DrainAllAsync(_usageMessageQueue, - message => DeliverUsageAsync(context, message, cancellationToken), cancellationToken); + message => DeliverUsageInternalAsync(context, message, cancellationToken), cancellationToken); _recorder.TraceInformation(context.ToCall(), "Drained all usage messages"); @@ -149,7 +191,7 @@ await DrainAllAsync(_usageMessageQueue, public async Task> DrainAllAuditsAsync(ICallerContext context, CancellationToken cancellationToken) { await DrainAllAsync(_auditMessageQueueRepository, - message => DeliverAuditAsync(context, message, cancellationToken), cancellationToken); + message => DeliverAuditInternalAsync(context, message, cancellationToken), cancellationToken); _recorder.TraceInformation(context.ToCall(), "Drained all audit messages"); @@ -193,12 +235,12 @@ public async Task, Error>> SearchAllEmailDe deliveries.Select(delivery => delivery.ToDeliveredEmail())); } - private async Task> DeliverEmailAsync(ICallerContext context, EmailMessage message, + private async Task> DeliverEmailInternalAsync(ICallerContext context, EmailMessage message, CancellationToken cancellationToken) { if (message.Html.IsInvalidParameter(x => x.Exists(), nameof(EmailMessage.Html), out _)) { - return Error.RuleViolation(Resources.AncillaryApplication_MissingEmailHtml); + return Error.RuleViolation(Resources.AncillaryApplication_Email_MissingHtml); } var messageId = QueuedMessageId.Create(message.MessageId!); @@ -327,38 +369,38 @@ private async Task> DeliverEmailAsync(ICallerContext context return true; } - private async Task> DeliverUsageAsync(ICallerContext context, UsageMessage message, + private async Task> DeliverUsageInternalAsync(ICallerContext context, UsageMessage message, CancellationToken cancellationToken) { if (message.ForId.IsInvalidParameter(x => x.HasValue(), nameof(UsageMessage.ForId), out _)) { - return Error.RuleViolation(Resources.AncillaryApplication_MissingUsageForId); + return Error.RuleViolation(Resources.AncillaryApplication_Usage_MissingForId); } if (message.EventName.IsInvalidParameter(x => x.HasValue(), nameof(UsageMessage.EventName), out _)) { - return Error.RuleViolation(Resources.AncillaryApplication_MissingUsageEventName); + return Error.RuleViolation(Resources.AncillaryApplication_Usage_MissingEventName); } - await _usageDeliveryService.DeliverAsync(context, message.ForId!, message.EventName!, message.Additional, + var delivered = await _usageDeliveryService.DeliverAsync(context, message.ForId!, message.EventName!, + message.Additional, cancellationToken); + if (!delivered.IsSuccessful) + { + return delivered.Error; + } _recorder.TraceInformation(context.ToCall(), "Delivered usage for {For}", message.ForId!); return true; } - private async Task> DeliverAuditAsync(ICallerContext context, AuditMessage message, + private async Task> DeliverAuditInternalAsync(ICallerContext context, AuditMessage message, CancellationToken cancellationToken) { if (message.AuditCode.IsInvalidParameter(x => x.HasValue(), nameof(AuditMessage.AuditCode), out _)) { - return Error.RuleViolation(Resources.AncillaryApplication_MissingAuditCode); - } - - if (message.TenantId.IsInvalidParameter(x => x.HasValue(), nameof(AuditMessage.TenantId), out _)) - { - return Error.RuleViolation(Resources.AncillaryApplication_MissingTenantId); + return Error.RuleViolation(Resources.AncillaryApplication_Audit_MissingCode); } var templateArguments = TemplateArguments.Create(message.Arguments ?? new List()); @@ -386,6 +428,45 @@ private async Task> DeliverAuditAsync(ICallerContext context return true; } + private async Task> DeliverProvisioningInternalAsync(ICallerContext context, + ProvisioningMessage message, CancellationToken cancellationToken) + { + if (message.TenantId.IsInvalidParameter(x => x.HasValue(), nameof(ProvisioningMessage.TenantId), out _)) + { + return Error.RuleViolation(Resources.AncillaryApplication_Provisioning_MissingTenantId); + } + + var tenantSettings = new TenantSettings(message.Settings.ToDictionary(pair => pair.Key, + pair => + { + var value = pair.Value.Value; + if (value is JsonElement jsonElement) + { + value = jsonElement.ValueKind switch + { + JsonValueKind.String => jsonElement.GetString(), + JsonValueKind.Number => jsonElement.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null + }; + } + + return new TenantSetting(value); + })); + var delivered = + await _provisioningDeliveryService.DeliverAsync(context, message.TenantId!, tenantSettings, + cancellationToken); + if (!delivered.IsSuccessful) + { + return delivered.Error; + } + + _recorder.TraceInformation(context.ToCall(), "Delivered provisioning for {Tenant}", message.TenantId!); + + return true; + } + #if TESTINGONLY private static async Task DrainAllAsync(IMessageQueueStore repository, Func>> handler, diff --git a/src/AncillaryApplication/IAncillaryApplication.cs b/src/AncillaryApplication/IAncillaryApplication.cs index 283b87fb..0bcc47a1 100644 --- a/src/AncillaryApplication/IAncillaryApplication.cs +++ b/src/AncillaryApplication/IAncillaryApplication.cs @@ -13,6 +13,9 @@ Task> DeliverAuditAsync(ICallerContext context, string messa Task> DeliverEmailAsync(ICallerContext context, string messageAsJson, CancellationToken cancellationToken); + Task> DeliverProvisioningAsync(ICallerContext context, string messageAsJson, + CancellationToken cancellationToken); + Task> DeliverUsageAsync(ICallerContext context, string messageAsJson, CancellationToken cancellationToken); @@ -24,6 +27,10 @@ Task> DeliverUsageAsync(ICallerContext context, string messa Task> DrainAllEmailsAsync(ICallerContext context, CancellationToken cancellationToken); #endif +#if TESTINGONLY + Task> DrainAllProvisioningsAsync(ICallerContext context, CancellationToken cancellationToken); +#endif + #if TESTINGONLY Task> DrainAllUsagesAsync(ICallerContext context, CancellationToken cancellationToken); #endif diff --git a/src/AncillaryApplication/Resources.Designer.cs b/src/AncillaryApplication/Resources.Designer.cs index 104e1266..36e34db2 100644 --- a/src/AncillaryApplication/Resources.Designer.cs +++ b/src/AncillaryApplication/Resources.Designer.cs @@ -60,56 +60,56 @@ internal Resources() { } /// - /// Looks up a localized string similar to The queued message was not valid JSON for its type: '{0}', message was: {1}. + /// Looks up a localized string similar to The audit message is missing a 'AuditCode'. /// - internal static string AncillaryApplication_InvalidQueuedMessage { + internal static string AncillaryApplication_Audit_MissingCode { get { - return ResourceManager.GetString("AncillaryApplication_InvalidQueuedMessage", resourceCulture); + return ResourceManager.GetString("AncillaryApplication_Audit_MissingCode", resourceCulture); } } /// - /// Looks up a localized string similar to The audit message is missing a 'AuditCode'. + /// Looks up a localized string similar to The email message is missing the 'HTML' email. /// - internal static string AncillaryApplication_MissingAuditCode { + internal static string AncillaryApplication_Email_MissingHtml { get { - return ResourceManager.GetString("AncillaryApplication_MissingAuditCode", resourceCulture); + return ResourceManager.GetString("AncillaryApplication_Email_MissingHtml", resourceCulture); } } /// - /// Looks up a localized string similar to The email message is missing the 'HTML' email. + /// Looks up a localized string similar to The queued message was not valid JSON for its type: '{0}', message was: {1}. /// - internal static string AncillaryApplication_MissingEmailHtml { + internal static string AncillaryApplication_InvalidQueuedMessage { get { - return ResourceManager.GetString("AncillaryApplication_MissingEmailHtml", resourceCulture); + return ResourceManager.GetString("AncillaryApplication_InvalidQueuedMessage", resourceCulture); } } /// - /// Looks up a localized string similar to The audit message is missing a 'TenantId'. + /// Looks up a localized string similar to The provisioning message is missing the 'TenantId' identifier. /// - internal static string AncillaryApplication_MissingTenantId { + internal static string AncillaryApplication_Provisioning_MissingTenantId { get { - return ResourceManager.GetString("AncillaryApplication_MissingTenantId", resourceCulture); + return ResourceManager.GetString("AncillaryApplication_Provisioning_MissingTenantId", resourceCulture); } } /// /// Looks up a localized string similar to The usage message is missing a 'EventName'. /// - internal static string AncillaryApplication_MissingUsageEventName { + internal static string AncillaryApplication_Usage_MissingEventName { get { - return ResourceManager.GetString("AncillaryApplication_MissingUsageEventName", resourceCulture); + return ResourceManager.GetString("AncillaryApplication_Usage_MissingEventName", resourceCulture); } } /// /// Looks up a localized string similar to The usage message is missing a 'ForId'. /// - internal static string AncillaryApplication_MissingUsageForId { + internal static string AncillaryApplication_Usage_MissingForId { get { - return ResourceManager.GetString("AncillaryApplication_MissingUsageForId", resourceCulture); + return ResourceManager.GetString("AncillaryApplication_Usage_MissingForId", resourceCulture); } } } diff --git a/src/AncillaryApplication/Resources.resx b/src/AncillaryApplication/Resources.resx index 7721faac..6650f099 100644 --- a/src/AncillaryApplication/Resources.resx +++ b/src/AncillaryApplication/Resources.resx @@ -27,19 +27,19 @@ The queued message was not valid JSON for its type: '{0}', message was: {1} - + The usage message is missing a 'ForId' - + The usage message is missing a 'EventName' - + The audit message is missing a 'AuditCode' - - The audit message is missing a 'TenantId' - - + The email message is missing the 'HTML' email + + The provisioning message is missing the 'TenantId' identifier + \ No newline at end of file diff --git a/src/AncillaryDomain/AuditRoot.cs b/src/AncillaryDomain/AuditRoot.cs index efcee563..a87dc30b 100644 --- a/src/AncillaryDomain/AuditRoot.cs +++ b/src/AncillaryDomain/AuditRoot.cs @@ -1,4 +1,5 @@ using Common; +using Common.Extensions; using Domain.Common.Entities; using Domain.Common.Identity; using Domain.Common.ValueObjects; @@ -11,7 +12,7 @@ namespace AncillaryDomain; public sealed class AuditRoot : AggregateRootBase { public static Result Create(IRecorder recorder, IIdentifierFactory idFactory, - Identifier againstId, Identifier organizationId, string auditCode, Optional messageTemplate, + Identifier againstId, Optional organizationId, string auditCode, Optional messageTemplate, TemplateArguments templateArguments) { var root = new AuditRoot(recorder, idFactory); @@ -63,7 +64,9 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco { case Events.Audits.Created created: { - OrganizationId = created.OrganizationId.ToId(); + OrganizationId = created.OrganizationId.HasValue() + ? created.OrganizationId.ToId() + : Optional.None; AgainstId = created.AgainstId.ToId(); AuditCode = created.AuditCode; MessageTemplate = Optional.Some(created.MessageTemplate); diff --git a/src/AncillaryDomain/Events.cs b/src/AncillaryDomain/Events.cs index 6bc69c40..58a9aa33 100644 --- a/src/AncillaryDomain/Events.cs +++ b/src/AncillaryDomain/Events.cs @@ -117,14 +117,16 @@ public static class Audits { public class Created : IDomainEvent { - public static Created Create(Identifier id, Identifier againstId, Identifier organizationId, + public static Created Create(Identifier id, Identifier againstId, Optional organizationId, string auditCode, Optional messageTemplate, TemplateArguments templateArguments) { return new Created { RootId = id, OccurredUtc = DateTime.UtcNow, - OrganizationId = organizationId, + OrganizationId = organizationId.HasValue + ? organizationId.Value.Text + : null, AgainstId = againstId, AuditCode = auditCode, MessageTemplate = messageTemplate.ValueOrDefault ?? string.Empty, @@ -138,7 +140,7 @@ public static Created Create(Identifier id, Identifier againstId, Identifier org public required string MessageTemplate { get; set; } - public required string OrganizationId { get; set; } + public string? OrganizationId { get; set; } public required List TemplateArguments { get; set; } diff --git a/src/AncillaryInfrastructure.IntegrationTests/AuditsApiSpec.cs b/src/AncillaryInfrastructure.IntegrationTests/AuditsApiSpec.cs index dda348d1..8d72f5b9 100644 --- a/src/AncillaryInfrastructure.IntegrationTests/AuditsApiSpec.cs +++ b/src/AncillaryInfrastructure.IntegrationTests/AuditsApiSpec.cs @@ -1,4 +1,3 @@ -using AncillaryInfrastructure.IntegrationTests.Stubs; using ApiHost1; using Application.Persistence.Shared; using Application.Persistence.Shared.ReadModels; @@ -30,12 +29,15 @@ public AuditsApiSpec(WebApiSetup setup) : base(setup, OverrideDependenc [Fact] public async Task WhenDeliverAudit_ThenDelivers() { + var login = await LoginUserAsync(LoginUser.Operator); + var tenantId = login.User.Profile!.DefaultOrganizationId!; + var request = new DeliverAuditRequest { Message = new AuditMessage { MessageId = "amessageid", - TenantId = "atenantid", + TenantId = tenantId, CallId = "acallid", CallerId = "acallerid", AuditCode = "anauditcode", @@ -51,11 +53,11 @@ public async Task WhenDeliverAudit_ThenDelivers() #if TESTINGONLY var audits = await Api.GetAsync(new SearchAllAuditsRequest { - OrganizationId = "atenantid" + OrganizationId = tenantId }); audits.Content.Value.Audits!.Count.Should().Be(1); - audits.Content.Value.Audits[0].OrganizationId.Should().Be("atenantid"); + audits.Content.Value.Audits[0].OrganizationId.Should().Be(tenantId); audits.Content.Value.Audits[0].MessageTemplate.Should().Be("amessagetemplate"); audits.Content.Value.Audits[0].TemplateArguments.Count.Should().Be(2); audits.Content.Value.Audits[0].TemplateArguments[0].Should().Be("anarg1"); @@ -67,10 +69,16 @@ public async Task WhenDeliverAudit_ThenDelivers() [Fact] public async Task WhenDrainAllAuditsAndNone_ThenDoesNotDrainAny() { + var login = await LoginUserAsync(LoginUser.Operator); + var tenantId = login.User.Profile!.DefaultOrganizationId!; + var request = new DrainAllAuditsRequest(); await Api.PostAsync(request, req => req.SetHMACAuth(request, "asecret")); - var audits = await Api.GetAsync(new SearchAllAuditsRequest()); + var audits = await Api.GetAsync(new SearchAllAuditsRequest + { + OrganizationId = tenantId + }); audits.Content.Value.Audits!.Count.Should().Be(0); } @@ -78,13 +86,15 @@ public async Task WhenDrainAllAuditsAndNone_ThenDoesNotDrainAny() #if TESTINGONLY [Fact] - public async Task WhenDrainAllAuditsAndSome_ThenDrains() + public async Task WhenDrainAllAuditsAndSomeWithUnknownTenancies_ThenDrains() { - var call = CallContext.CreateCustom("acallid", "acallerid", "atenantid"); + var login = await LoginUserAsync(); + var tenantId = login.User.Profile!.DefaultOrganizationId; + var call = CallContext.CreateCustom("acallid", "acallerid", tenantId); await _auditMessageQueue.PushAsync(call, new AuditMessage { MessageId = "amessageid1", - TenantId = "atenantid1", + TenantId = tenantId, AuditCode = "anauditcode1", MessageTemplate = "amessagetemplate1", Arguments = new List { "anarg1" } @@ -92,7 +102,7 @@ public async Task WhenDrainAllAuditsAndSome_ThenDrains() await _auditMessageQueue.PushAsync(call, new AuditMessage { MessageId = "amessageid2", - TenantId = "atenantid1", + TenantId = tenantId, AuditCode = "anauditcode2", MessageTemplate = "amessagetemplate2", Arguments = new List { "anarg2" } @@ -111,15 +121,15 @@ public async Task WhenDrainAllAuditsAndSome_ThenDrains() var audits = await Api.GetAsync(new SearchAllAuditsRequest { - OrganizationId = "atenantid1" + OrganizationId = tenantId }); audits.Content.Value.Audits!.Count.Should().Be(2); - audits.Content.Value.Audits[0].OrganizationId.Should().Be("atenantid1"); + audits.Content.Value.Audits[0].OrganizationId.Should().Be(tenantId); audits.Content.Value.Audits[0].AuditCode.Should().Be("anauditcode1"); audits.Content.Value.Audits[0].MessageTemplate.Should().Be("amessagetemplate1"); audits.Content.Value.Audits[0].TemplateArguments[0].Should().Be("anarg1"); - audits.Content.Value.Audits[1].OrganizationId.Should().Be("atenantid1"); + audits.Content.Value.Audits[1].OrganizationId.Should().Be(tenantId); audits.Content.Value.Audits[1].AuditCode.Should().Be("anauditcode2"); audits.Content.Value.Audits[1].MessageTemplate.Should().Be("amessagetemplate2"); audits.Content.Value.Audits[1].TemplateArguments[0].Should().Be("anarg2"); @@ -128,6 +138,6 @@ public async Task WhenDrainAllAuditsAndSome_ThenDrains() private static void OverrideDependencies(IServiceCollection services) { - services.AddSingleton(); + // nothing here yet } } \ No newline at end of file diff --git a/src/AncillaryInfrastructure.IntegrationTests/ProvisioningsApiSpec.cs b/src/AncillaryInfrastructure.IntegrationTests/ProvisioningsApiSpec.cs new file mode 100644 index 00000000..6b4e23ab --- /dev/null +++ b/src/AncillaryInfrastructure.IntegrationTests/ProvisioningsApiSpec.cs @@ -0,0 +1,120 @@ +using System.Text.Json; +using ApiHost1; +using Application.Interfaces.Services; +using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; +using Common; +using Common.Extensions; +using FluentAssertions; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; +using Infrastructure.Web.Api.Operations.Shared.Organizations; +using IntegrationTesting.WebApi.Common; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Task = System.Threading.Tasks.Task; + +namespace AncillaryInfrastructure.IntegrationTests; + +[Trait("Category", "Integration.Web")] +[Collection("API")] +public class ProvisioningsApiSpec : WebApiSpec +{ + private readonly IProvisioningMessageQueue _provisioningMessageQueue; + + public ProvisioningsApiSpec(WebApiSetup setup) : base(setup, OverrideDependencies) + { + EmptyAllRepositories(); + _provisioningMessageQueue = setup.GetRequiredService(); + _provisioningMessageQueue.DestroyAllAsync(CancellationToken.None).GetAwaiter().GetResult(); + } + + [Fact] + public async Task WhenDeliverProvisioning_ThenDelivers() + { + var login = await LoginUserAsync(LoginUser.Operator); + var tenantId = login.User.Profile!.DefaultOrganizationId!; + + var request = new DeliverProvisioningRequest + { + Message = new ProvisioningMessage + { + MessageId = "amessageid", + TenantId = tenantId, + CallId = "acallid", + CallerId = "acallerid", + Settings = new Dictionary + { + { "aname1", new TenantSetting("avalue") }, + { "aname2", new TenantSetting(99) }, + { "aname3", new TenantSetting(true) } + } + }.ToJson()! + }; + var result = await Api.PostAsync(request, req => req.SetHMACAuth(request, "asecret")); + + result.Content.Value.IsDelivered.Should().BeTrue(); + +#if TESTINGONLY + var organization = await Api.GetAsync(new GetOrganizationSettingsRequest + { + Id = tenantId + }, req => req.SetJWTBearerToken(login.AccessToken)); + + organization.Content.Value.Settings!.Count.Should().Be(3); + organization.Content.Value.Settings["aname1"].As().GetString().Should().Be("avalue"); + organization.Content.Value.Settings["aname2"].As().GetString().Should().Be("99"); + organization.Content.Value.Settings["aname3"].As().GetString().Should().Be("True"); +#endif + } + +#if TESTINGONLY + [Fact] + public async Task WhenDrainAllProvisioningsAndSomeWithUnknownTenancies_ThenDrains() + { + var login = await LoginUserAsync(); + var tenantId = login.User.Profile!.DefaultOrganizationId; + var call = CallContext.CreateCustom("acallid", "acallerid", tenantId); + await _provisioningMessageQueue.PushAsync(call, new ProvisioningMessage + { + MessageId = "amessageid1", + TenantId = tenantId, + Settings = new Dictionary + { + { "aname1", new TenantSetting("avalue1") }, + { "aname2", new TenantSetting(99) }, + { "aname3", new TenantSetting(true) } + } + }, CancellationToken.None); + await _provisioningMessageQueue.PushAsync(call, new ProvisioningMessage + { + MessageId = "amessageid3", + TenantId = "anothertenantid", + Settings = new Dictionary + { + { "aname1", new TenantSetting("avalue3") }, + { "aname2", new TenantSetting(999) }, + { "aname3", new TenantSetting(false) } + } + }, CancellationToken.None); + + var request = new DrainAllProvisioningsRequest(); + await Api.PostAsync(request, req => req.SetHMACAuth(request, "asecret")); + + var organization = await Api.GetAsync(new GetOrganizationSettingsRequest + { + Id = tenantId! + }, req => req.SetJWTBearerToken(login.AccessToken)); + + organization.Content.Value.Settings!.Count.Should().Be(3); + organization.Content.Value.Settings["aname1"].As().GetString().Should().Be("avalue1"); + organization.Content.Value.Settings["aname2"].As().GetString().Should().Be("99"); + organization.Content.Value.Settings["aname3"].As().GetString().Should().Be("True"); + } +#endif + + private static void OverrideDependencies(IServiceCollection services) + { + // nothing here yet + } +} \ No newline at end of file diff --git a/src/AncillaryInfrastructure.UnitTests/Api/Provisionings/DeliverProvisioningRequestValidatorSpec.cs b/src/AncillaryInfrastructure.UnitTests/Api/Provisionings/DeliverProvisioningRequestValidatorSpec.cs new file mode 100644 index 00000000..c1ba0b3e --- /dev/null +++ b/src/AncillaryInfrastructure.UnitTests/Api/Provisionings/DeliverProvisioningRequestValidatorSpec.cs @@ -0,0 +1,40 @@ +using AncillaryInfrastructure.Api.Provisionings; +using FluentAssertions; +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; +using UnitTesting.Common.Validation; +using Xunit; + +namespace AncillaryInfrastructure.UnitTests.Api.Provisionings; + +[Trait("Category", "Unit")] +public class DeliverProvisioningRequestValidatorSpec +{ + private readonly DeliverProvisioningRequest _dto; + private readonly DeliverProvisioningRequestValidator _validator; + + public DeliverProvisioningRequestValidatorSpec() + { + _validator = new DeliverProvisioningRequestValidator(); + _dto = new DeliverProvisioningRequest + { + Message = "amessage" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenMessageIsNull_ThenThrows() + { + _dto.Message = null!; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AnyQueueMessageValidator_InvalidMessage); + } +} \ No newline at end of file diff --git a/src/AncillaryInfrastructure/AncillaryModule.cs b/src/AncillaryInfrastructure/AncillaryModule.cs index 9adffd04..e555c1bc 100644 --- a/src/AncillaryInfrastructure/AncillaryModule.cs +++ b/src/AncillaryInfrastructure/AncillaryModule.cs @@ -71,9 +71,13 @@ public Action RegisterServices c => new EmailDeliveryProjection(c.ResolveForUnshared(), c.ResolveForUnshared(), c.ResolveForPlatform())); + services.RegisterUnshared(c => + new ProvisioningMessageQueue(c.Resolve(), c.Resolve(), + c.ResolveForPlatform())); services.RegisterUnshared(); services.RegisterUnshared(); + services.RegisterUnshared(); }; } } diff --git a/src/AncillaryInfrastructure/Api/Provisionings/DeliverProvisioningRequestValidator.cs b/src/AncillaryInfrastructure/Api/Provisionings/DeliverProvisioningRequestValidator.cs new file mode 100644 index 00000000..87a9abcf --- /dev/null +++ b/src/AncillaryInfrastructure/Api/Provisionings/DeliverProvisioningRequestValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; +using JetBrains.Annotations; + +namespace AncillaryInfrastructure.Api.Provisionings; + +[UsedImplicitly] +public class DeliverProvisioningRequestValidator : AbstractValidator +{ + public DeliverProvisioningRequestValidator() + { + RuleFor(req => req.Message) + .NotEmpty() + .WithMessage(Resources.AnyQueueMessageValidator_InvalidMessage); + } +} \ No newline at end of file diff --git a/src/AncillaryInfrastructure/Api/Provisionings/ProvisioningsApi.cs b/src/AncillaryInfrastructure/Api/Provisionings/ProvisioningsApi.cs new file mode 100644 index 00000000..a0f37a53 --- /dev/null +++ b/src/AncillaryInfrastructure/Api/Provisionings/ProvisioningsApi.cs @@ -0,0 +1,43 @@ +using AncillaryApplication; +using Common; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; + +namespace AncillaryInfrastructure.Api.Provisionings; + +public sealed class ProvisioningsApi : IWebApiService +{ + private readonly IAncillaryApplication _ancillaryApplication; + private readonly ICallerContextFactory _contextFactory; + + public ProvisioningsApi(ICallerContextFactory contextFactory, IAncillaryApplication ancillaryApplication) + { + _contextFactory = contextFactory; + _ancillaryApplication = ancillaryApplication; + } + + public async Task> Deliver(DeliverProvisioningRequest request, + CancellationToken cancellationToken) + { + var delivered = + await _ancillaryApplication.DeliverProvisioningAsync(_contextFactory.Create(), request.Message, + cancellationToken); + + return () => delivered.HandleApplicationResult(_ => + new PostResult(new DeliverMessageResponse { IsDelivered = true })); + } + +#if TESTINGONLY + public async Task DrainAll(DrainAllProvisioningsRequest request, + CancellationToken cancellationToken) + { + var result = + await _ancillaryApplication.DrainAllProvisioningsAsync(_contextFactory.Create(), cancellationToken); + + return () => result.Match(() => new Result(), + error => new Result(error)); + } +#endif +} \ No newline at end of file diff --git a/src/AncillaryInfrastructure/ApplicationServices/OrganizationProvisioningDeliveryService.cs b/src/AncillaryInfrastructure/ApplicationServices/OrganizationProvisioningDeliveryService.cs new file mode 100644 index 00000000..e967c11a --- /dev/null +++ b/src/AncillaryInfrastructure/ApplicationServices/OrganizationProvisioningDeliveryService.cs @@ -0,0 +1,23 @@ +using Application.Interfaces; +using Application.Interfaces.Services; +using Application.Persistence.Shared; +using Application.Services.Shared; +using Common; + +namespace AncillaryInfrastructure.ApplicationServices; + +public class OrganizationProvisioningDeliveryService : IProvisioningDeliveryService +{ + private readonly IOrganizationsService _organizationsService; + + public OrganizationProvisioningDeliveryService(IOrganizationsService organizationsService) + { + _organizationsService = organizationsService; + } + + public async Task> DeliverAsync(ICallerContext context, string tenantId, + TenantSettings settings, CancellationToken cancellationToken) + { + return await _organizationsService.ChangeSettingsPrivateAsync(context, tenantId, settings, cancellationToken); + } +} \ No newline at end of file diff --git a/src/ApiHost1/ApiHost1.csproj b/src/ApiHost1/ApiHost1.csproj index 7f1877ed..e7ce90b0 100644 --- a/src/ApiHost1/ApiHost1.csproj +++ b/src/ApiHost1/ApiHost1.csproj @@ -15,6 +15,7 @@ + diff --git a/src/ApiHost1/ApiHostModule.cs b/src/ApiHost1/ApiHostModule.cs index 6a479495..9675d50b 100644 --- a/src/ApiHost1/ApiHostModule.cs +++ b/src/ApiHost1/ApiHostModule.cs @@ -1,15 +1,5 @@ using System.Reflection; -using AncillaryInfrastructure.Api.Usages; -using Application.Persistence.Shared; -using Application.Services.Shared; -using Common; -using Domain.Interfaces; -using Domain.Services.Shared.DomainServices; -using Infrastructure.Hosting.Common.Extensions; -using Infrastructure.Persistence.Interfaces; -using Infrastructure.Persistence.Shared.ApplicationServices; -using Infrastructure.Shared.ApplicationServices; -using Infrastructure.Shared.DomainServices; +using ApiHost1.Api.Health; using Infrastructure.Web.Hosting.Common; namespace ApiHost1; @@ -19,7 +9,7 @@ namespace ApiHost1; /// public class ApiHostModule : ISubDomainModule { - public Assembly ApiAssembly => typeof(UsagesApi).Assembly; + public Assembly ApiAssembly => typeof(HealthApi).Assembly; public Assembly DomainAssembly => null!; @@ -27,23 +17,22 @@ public class ApiHostModule : ISubDomainModule public Action> ConfigureMiddleware { - get { return (_, _) => { }; } + get + { + return (_, _) => + { + // Add you host specific middleware here + }; + } } public Action RegisterServices { get { - return (_, services) => + return (_, _) => { - services.RegisterUnshared(c => - new EmailMessageQueue(c.Resolve(), c.Resolve(), - c.ResolveForPlatform())); - - services.RegisterUnshared(); - services.RegisterUnshared(); - services.RegisterUnshared(); - services.RegisterUnshared(); + // Add your host specific dependencies here }; } } diff --git a/src/ApiHost1/HostedModules.cs b/src/ApiHost1/HostedModules.cs index a079f854..09f6c384 100644 --- a/src/ApiHost1/HostedModules.cs +++ b/src/ApiHost1/HostedModules.cs @@ -4,6 +4,7 @@ using EndUsersInfrastructure; using IdentityInfrastructure; using Infrastructure.Web.Hosting.Common; +using OrganizationsInfrastructure; namespace ApiHost1; @@ -14,6 +15,7 @@ public static SubDomainModules Get() var modules = new SubDomainModules(); modules.Register(new ApiHostModule()); modules.Register(new EndUsersModule()); + modules.Register(new OrganizationsModule()); modules.Register(new IdentityModule()); modules.Register(new AncillaryModule()); #if TESTINGONLY diff --git a/src/Application.Interfaces/MultiTenancyConstants.cs b/src/Application.Interfaces/MultiTenancyConstants.cs deleted file mode 100644 index 0953e90a..00000000 --- a/src/Application.Interfaces/MultiTenancyConstants.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Application.Interfaces; - -public static class MultiTenancyConstants -{ - public const string DefaultOrganizationId = "org_01234567890123456789012"; -} \ No newline at end of file diff --git a/src/Application.Interfaces/Resources/Tenants.cs b/src/Application.Interfaces/Resources/Tenants.cs deleted file mode 100644 index 29f1a93c..00000000 --- a/src/Application.Interfaces/Resources/Tenants.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Application.Interfaces.Resources; - -/// -/// Defines a setting for a specific tenant -/// -public class TenantSetting -{ - public bool IsEncrypted { get; set; } - - public string? Value { get; set; } -} \ No newline at end of file diff --git a/src/Application.Interfaces/Services/ITenantSettingsService.cs b/src/Application.Interfaces/Services/ITenantSettingsService.cs index 35612d58..ed9b7619 100644 --- a/src/Application.Interfaces/Services/ITenantSettingsService.cs +++ b/src/Application.Interfaces/Services/ITenantSettingsService.cs @@ -1,11 +1,12 @@ -using Application.Interfaces.Resources; +using Common; namespace Application.Interfaces.Services; /// -/// Defines an application service for working with tenant-specific settings +/// Defines a service for creating tenant-specific settings /// public interface ITenantSettingsService { - IReadOnlyDictionary CreateForNewTenant(ICallerContext context, string tenantId); + Task> CreateForTenantAsync(ICallerContext context, string tenantId, + CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Application.Interfaces/Services/TenantSettings.cs b/src/Application.Interfaces/Services/TenantSettings.cs new file mode 100644 index 00000000..04647692 --- /dev/null +++ b/src/Application.Interfaces/Services/TenantSettings.cs @@ -0,0 +1,47 @@ +using System.Collections.ObjectModel; + +namespace Application.Interfaces.Services; + +/// +/// Defines a collection of +/// +public sealed class TenantSettings : ReadOnlyDictionary +{ + public TenantSettings() : this(new Dictionary()) + { + } + + public TenantSettings(IDictionary dictionary) : base(dictionary) + { + } + + public TenantSettings(IDictionary dictionary) : base(dictionary.ToDictionary(pair => pair.Key, + pair => new TenantSetting + { + Value = pair.Value + })) + { + } +} + +/// +/// Defines a setting for a specific tenant +/// +public sealed class TenantSetting +{ + public TenantSetting() + { + IsEncrypted = false; + Value = null; + } + + public TenantSetting(object? value, bool isEncrypted = false) + { + IsEncrypted = isEncrypted; + Value = value; + } + + public bool IsEncrypted { get; set; } + + public object? Value { get; set; } +} \ No newline at end of file diff --git a/src/Application.Interfaces/WorkerConstants.cs b/src/Application.Interfaces/WorkerConstants.cs new file mode 100644 index 00000000..2bd7642d --- /dev/null +++ b/src/Application.Interfaces/WorkerConstants.cs @@ -0,0 +1,12 @@ +namespace Application.Interfaces; + +public static class WorkerConstants +{ + public static class Queues + { + public const string Audits = "audits"; + public const string Emails = "emails"; + public const string Provisionings = "provisioning"; + public const string Usages = "usages"; + } +} \ No newline at end of file diff --git a/src/Application.Persistence.Shared/IProvisioningDeliveryService.cs b/src/Application.Persistence.Shared/IProvisioningDeliveryService.cs new file mode 100644 index 00000000..e6c3aa7c --- /dev/null +++ b/src/Application.Persistence.Shared/IProvisioningDeliveryService.cs @@ -0,0 +1,17 @@ +using Application.Interfaces; +using Application.Interfaces.Services; +using Common; + +namespace Application.Persistence.Shared; + +/// +/// Defines a service to which we can deliver provisioning events +/// +public interface IProvisioningDeliveryService +{ + /// + /// Delivers the provisioning event + /// + Task> DeliverAsync(ICallerContext context, string tenantId, TenantSettings settings, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Application.Persistence.Shared/IProvisioningMessageQueue.cs b/src/Application.Persistence.Shared/IProvisioningMessageQueue.cs new file mode 100644 index 00000000..4b8551c3 --- /dev/null +++ b/src/Application.Persistence.Shared/IProvisioningMessageQueue.cs @@ -0,0 +1,10 @@ +using Application.Persistence.Interfaces; +using Application.Persistence.Shared.ReadModels; +using Common; + +namespace Application.Persistence.Shared; + +public interface IProvisioningMessageQueue : IMessageQueueStore, IApplicationRepository +{ + new Task> DestroyAllAsync(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Application.Persistence.Shared/ReadModels/AuditMessage.cs b/src/Application.Persistence.Shared/ReadModels/AuditMessage.cs index 10238e7d..ae3f870b 100644 --- a/src/Application.Persistence.Shared/ReadModels/AuditMessage.cs +++ b/src/Application.Persistence.Shared/ReadModels/AuditMessage.cs @@ -1,8 +1,9 @@ -using QueryAny; +using Application.Interfaces; +using QueryAny; namespace Application.Persistence.Shared.ReadModels; -[EntityName("audits")] +[EntityName(WorkerConstants.Queues.Audits)] public class AuditMessage : QueuedMessage { public string? AgainstId { get; set; } diff --git a/src/Application.Persistence.Shared/ReadModels/EmailMessage.cs b/src/Application.Persistence.Shared/ReadModels/EmailMessage.cs index a89d5659..a2c85ff9 100644 --- a/src/Application.Persistence.Shared/ReadModels/EmailMessage.cs +++ b/src/Application.Persistence.Shared/ReadModels/EmailMessage.cs @@ -1,8 +1,9 @@ -using QueryAny; +using Application.Interfaces; +using QueryAny; namespace Application.Persistence.Shared.ReadModels; -[EntityName("emails")] +[EntityName(WorkerConstants.Queues.Emails)] public class EmailMessage : QueuedMessage { public QueuedEmailHtmlMessage? Html { get; set; } diff --git a/src/Application.Persistence.Shared/ReadModels/ProvisioningMessage.cs b/src/Application.Persistence.Shared/ReadModels/ProvisioningMessage.cs new file mode 100644 index 00000000..f6097785 --- /dev/null +++ b/src/Application.Persistence.Shared/ReadModels/ProvisioningMessage.cs @@ -0,0 +1,11 @@ +using Application.Interfaces; +using Application.Interfaces.Services; +using QueryAny; + +namespace Application.Persistence.Shared.ReadModels; + +[EntityName(WorkerConstants.Queues.Provisionings)] +public class ProvisioningMessage : QueuedMessage +{ + public Dictionary Settings { get; set; } = new(); +} \ No newline at end of file diff --git a/src/Application.Persistence.Shared/ReadModels/UsageMessage.cs b/src/Application.Persistence.Shared/ReadModels/UsageMessage.cs index 88b23018..10d64750 100644 --- a/src/Application.Persistence.Shared/ReadModels/UsageMessage.cs +++ b/src/Application.Persistence.Shared/ReadModels/UsageMessage.cs @@ -1,8 +1,9 @@ -using QueryAny; +using Application.Interfaces; +using QueryAny; namespace Application.Persistence.Shared.ReadModels; -[EntityName("usages")] +[EntityName(WorkerConstants.Queues.Usages)] public class UsageMessage : QueuedMessage { public Dictionary? Additional { get; set; } diff --git a/src/Application.Resources.Shared/EndUser.cs b/src/Application.Resources.Shared/EndUser.cs index a2b75209..088eec7a 100644 --- a/src/Application.Resources.Shared/EndUser.cs +++ b/src/Application.Resources.Shared/EndUser.cs @@ -54,4 +54,6 @@ public class Membership : IIdentifiableResource public List Roles { get; set; } = new(); public required string Id { get; set; } + + public bool IsDefault { get; set; } } \ No newline at end of file diff --git a/src/Application.Resources.Shared/Organization.cs b/src/Application.Resources.Shared/Organization.cs new file mode 100644 index 00000000..d596b443 --- /dev/null +++ b/src/Application.Resources.Shared/Organization.cs @@ -0,0 +1,25 @@ +using Application.Interfaces.Resources; + +namespace Application.Resources.Shared; + +public class Organization : IIdentifiableResource +{ + public required string CreatedById { get; set; } + + public required string Name { get; set; } + + public OrganizationOwnership Ownership { get; set; } + + public required string Id { get; set; } +} + +public class OrganizationWithSettings : Organization +{ + public required Dictionary Settings { get; set; } +} + +public enum OrganizationOwnership +{ + Shared = 0, + Personal = 1 +} \ No newline at end of file diff --git a/src/Application.Resources.Shared/Profile.cs b/src/Application.Resources.Shared/Profile.cs index 0418f8fa..c48f2ab1 100644 --- a/src/Application.Resources.Shared/Profile.cs +++ b/src/Application.Resources.Shared/Profile.cs @@ -47,5 +47,5 @@ public class ProfileAddress public class ProfileWithDefaultMembership : Profile { - public string? DefaultOrganisationId { get; set; } + public string? DefaultOrganizationId { get; set; } } \ No newline at end of file diff --git a/src/Application.Services.Shared/IEndUsersService.cs b/src/Application.Services.Shared/IEndUsersService.cs index 86ec2275..74a43324 100644 --- a/src/Application.Services.Shared/IEndUsersService.cs +++ b/src/Application.Services.Shared/IEndUsersService.cs @@ -6,18 +6,20 @@ namespace Application.Services.Shared; public interface IEndUsersService { - Task, Error>> FindPersonByEmailAsync(ICallerContext caller, string emailAddress, + Task> CreateMembershipForCallerPrivateAsync(ICallerContext caller, string organizationId, CancellationToken cancellationToken); - Task> GetMembershipsAsync(ICallerContext caller, string id, + Task, Error>> FindPersonByEmailPrivateAsync(ICallerContext caller, string emailAddress, CancellationToken cancellationToken); - Task> GetPersonAsync(ICallerContext caller, string id, CancellationToken cancellationToken); + Task> GetMembershipsPrivateAsync(ICallerContext caller, string id, + CancellationToken cancellationToken); - Task> RegisterMachineAsync(ICallerContext caller, string name, string? timezone, + Task> RegisterMachinePrivateAsync(ICallerContext caller, string name, + string? timezone, string? countryCode, CancellationToken cancellationToken); - Task> RegisterPersonAsync(ICallerContext caller, string emailAddress, + Task> RegisterPersonPrivateAsync(ICallerContext caller, string emailAddress, string firstName, string? lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Application.Services.Shared/IOrganizationsService.cs b/src/Application.Services.Shared/IOrganizationsService.cs new file mode 100644 index 00000000..101abac0 --- /dev/null +++ b/src/Application.Services.Shared/IOrganizationsService.cs @@ -0,0 +1,18 @@ +using Application.Interfaces; +using Application.Interfaces.Services; +using Application.Resources.Shared; +using Common; + +namespace Application.Services.Shared; + +public interface IOrganizationsService +{ + Task> ChangeSettingsPrivateAsync(ICallerContext caller, string id, TenantSettings settings, + CancellationToken cancellationToken); + + Task> CreateOrganizationPrivateAsync(ICallerContext caller, string creatorId, + string name, OrganizationOwnership ownership, CancellationToken cancellationToken); + + Task> GetSettingsPrivateAsync(ICallerContext caller, string id, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/AzureFunctions.Api.WorkerHost/Functions/DeliverAudit.cs b/src/AzureFunctions.Api.WorkerHost/Functions/DeliverAudit.cs index 2932560e..c7f38961 100644 --- a/src/AzureFunctions.Api.WorkerHost/Functions/DeliverAudit.cs +++ b/src/AzureFunctions.Api.WorkerHost/Functions/DeliverAudit.cs @@ -1,6 +1,6 @@ -using Application.Persistence.Shared.ReadModels; +using Application.Interfaces; +using Application.Persistence.Shared.ReadModels; using Infrastructure.Workers.Api; -using Infrastructure.Workers.Api.Workers; using Microsoft.Azure.Functions.Worker; namespace AzureFunctions.Api.WorkerHost.Functions; @@ -15,7 +15,7 @@ public DeliverAudit(IQueueMonitoringApiRelayWorker worker) } [Function(nameof(DeliverAudit))] - public Task Run([QueueTrigger(DeliverAuditRelayWorker.QueueName)] AuditMessage message, + public Task Run([QueueTrigger(WorkerConstants.Queues.Audits)] AuditMessage message, FunctionContext context) { return _worker.RelayMessageOrThrowAsync(message, context.CancellationToken); diff --git a/src/AzureFunctions.Api.WorkerHost/Functions/DeliverEmail.cs b/src/AzureFunctions.Api.WorkerHost/Functions/DeliverEmail.cs index 36265e8a..ff948bdf 100644 --- a/src/AzureFunctions.Api.WorkerHost/Functions/DeliverEmail.cs +++ b/src/AzureFunctions.Api.WorkerHost/Functions/DeliverEmail.cs @@ -1,6 +1,6 @@ -using Application.Persistence.Shared.ReadModels; +using Application.Interfaces; +using Application.Persistence.Shared.ReadModels; using Infrastructure.Workers.Api; -using Infrastructure.Workers.Api.Workers; using Microsoft.Azure.Functions.Worker; namespace AzureFunctions.Api.WorkerHost.Functions; @@ -15,7 +15,7 @@ public DeliverEmail(IQueueMonitoringApiRelayWorker worker) } [Function(nameof(DeliverEmail))] - public Task Run([QueueTrigger(DeliverEmailRelayWorker.QueueName)] EmailMessage message, + public Task Run([QueueTrigger(WorkerConstants.Queues.Emails)] EmailMessage message, FunctionContext context) { return _worker.RelayMessageOrThrowAsync(message, context.CancellationToken); diff --git a/src/AzureFunctions.Api.WorkerHost/Functions/DeliverProvisioning.cs b/src/AzureFunctions.Api.WorkerHost/Functions/DeliverProvisioning.cs new file mode 100644 index 00000000..e5526c77 --- /dev/null +++ b/src/AzureFunctions.Api.WorkerHost/Functions/DeliverProvisioning.cs @@ -0,0 +1,23 @@ +using Application.Interfaces; +using Application.Persistence.Shared.ReadModels; +using Infrastructure.Workers.Api; +using Microsoft.Azure.Functions.Worker; + +namespace AzureFunctions.Api.WorkerHost.Functions; + +public sealed class DeliverProvisioning +{ + private readonly IQueueMonitoringApiRelayWorker _worker; + + public DeliverProvisioning(IQueueMonitoringApiRelayWorker worker) + { + _worker = worker; + } + + [Function(nameof(DeliverProvisioning))] + public Task Run([QueueTrigger(WorkerConstants.Queues.Provisionings)] ProvisioningMessage message, + FunctionContext context) + { + return _worker.RelayMessageOrThrowAsync(message, context.CancellationToken); + } +} \ No newline at end of file diff --git a/src/AzureFunctions.Api.WorkerHost/Functions/DeliverUsage.cs b/src/AzureFunctions.Api.WorkerHost/Functions/DeliverUsage.cs index a1c62c80..14836414 100644 --- a/src/AzureFunctions.Api.WorkerHost/Functions/DeliverUsage.cs +++ b/src/AzureFunctions.Api.WorkerHost/Functions/DeliverUsage.cs @@ -1,6 +1,6 @@ -using Application.Persistence.Shared.ReadModels; +using Application.Interfaces; +using Application.Persistence.Shared.ReadModels; using Infrastructure.Workers.Api; -using Infrastructure.Workers.Api.Workers; using Microsoft.Azure.Functions.Worker; namespace AzureFunctions.Api.WorkerHost.Functions; @@ -15,7 +15,7 @@ public DeliverUsage(IQueueMonitoringApiRelayWorker worker) } [Function(nameof(DeliverUsage))] - public Task Run([QueueTrigger(DeliverUsageRelayWorker.QueueName)] UsageMessage message, + public Task Run([QueueTrigger(WorkerConstants.Queues.Usages)] UsageMessage message, FunctionContext context) { return _worker.RelayMessageOrThrowAsync(message, context.CancellationToken); diff --git a/src/AzureFunctions.Api.WorkerHost/HostExtensions.cs b/src/AzureFunctions.Api.WorkerHost/HostExtensions.cs index 877a47ef..2df2ff5f 100644 --- a/src/AzureFunctions.Api.WorkerHost/HostExtensions.cs +++ b/src/AzureFunctions.Api.WorkerHost/HostExtensions.cs @@ -26,7 +26,7 @@ public static class HostExtensions public static void AddDependencies(this IServiceCollection services, HostBuilderContext context) { services.AddHttpClient(); - services.AddSingleton(new AspNetConfigurationSettings(context.Configuration)); + services.AddSingleton(new AspNetDynamicConfigurationSettings(context.Configuration)); services.AddSingleton(); services.AddSingleton(); @@ -51,5 +51,6 @@ public static void AddDependencies(this IServiceCollection services, HostBuilder services.AddSingleton, DeliverUsageRelayWorker>(); services.AddSingleton, DeliverAuditRelayWorker>(); services.AddSingleton, DeliverEmailRelayWorker>(); + services.AddSingleton, DeliverProvisioningRelayWorker>(); } } \ No newline at end of file diff --git a/src/BookingsInfrastructure/Api/Bookings/BookingsApi.cs b/src/BookingsInfrastructure/Api/Bookings/BookingsApi.cs index c0f18f7c..da60c288 100644 --- a/src/BookingsInfrastructure/Api/Bookings/BookingsApi.cs +++ b/src/BookingsInfrastructure/Api/Bookings/BookingsApi.cs @@ -1,4 +1,3 @@ -using Application.Interfaces; using Application.Resources.Shared; using BookingsApplication; using Infrastructure.Interfaces; @@ -22,8 +21,7 @@ public BookingsApi(ICallerContextFactory contextFactory, IBookingsApplication bo public async Task Cancel(CancelBookingRequest request, CancellationToken cancellationToken) { var booking = - await _bookingsApplication.CancelBookingAsync(_contextFactory.Create(), - MultiTenancyConstants.DefaultOrganizationId, request.Id, + await _bookingsApplication.CancelBookingAsync(_contextFactory.Create(), request.OrganizationId!, request.Id, cancellationToken); return () => booking.HandleApplicationResult(); } @@ -31,8 +29,7 @@ await _bookingsApplication.CancelBookingAsync(_contextFactory.Create(), public async Task> Make(MakeBookingRequest request, CancellationToken cancellationToken) { - var booking = await _bookingsApplication.MakeBookingAsync(_contextFactory.Create(), - MultiTenancyConstants.DefaultOrganizationId, + var booking = await _bookingsApplication.MakeBookingAsync(_contextFactory.Create(), request.OrganizationId!, request.CarId, request.StartUtc, request.EndUtc, cancellationToken); return () => booking.HandleApplicationResult(c => @@ -43,9 +40,8 @@ public async Task> SearchAll CancellationToken cancellationToken) { var bookings = await _bookingsApplication.SearchAllBookingsAsync(_contextFactory.Create(), - MultiTenancyConstants.DefaultOrganizationId, - request.FromUtc, - request.ToUtc, request.ToSearchOptions(), request.ToGetOptions(), cancellationToken); + request.OrganizationId!, request.FromUtc, request.ToUtc, request.ToSearchOptions(), request.ToGetOptions(), + cancellationToken); return () => bookings.HandleApplicationResult(c => new SearchAllBookingsResponse diff --git a/src/BookingsInfrastructure/BookingsModule.cs b/src/BookingsInfrastructure/BookingsModule.cs index 1d32ad83..72b480f1 100644 --- a/src/BookingsInfrastructure/BookingsModule.cs +++ b/src/BookingsInfrastructure/BookingsModule.cs @@ -20,7 +20,8 @@ public class BookingsModule : ISubDomainModule public Dictionary AggregatePrefixes => new() { - { typeof(BookingRoot), "booking" } + { typeof(BookingRoot), "booking" }, + { typeof(TripEntity), "trip" } }; public Action> ConfigureMiddleware diff --git a/src/CarsApplication/CarsApplication.cs b/src/CarsApplication/CarsApplication.cs index 62e21314..8b3b3ed7 100644 --- a/src/CarsApplication/CarsApplication.cs +++ b/src/CarsApplication/CarsApplication.cs @@ -93,7 +93,7 @@ public async Task> ScheduleMaintenanceCarAsync(ICallerContext public async Task> RegisterCarAsync(ICallerContext caller, string organizationId, string make, string model, int year, string location, string plate, CancellationToken cancellationToken) { - var retrieved = CarRoot.Create(_recorder, _idFactory, Identifier.Create(organizationId)); + var retrieved = CarRoot.Create(_recorder, _idFactory, organizationId.ToId()); if (!retrieved.IsSuccessful) { return retrieved.Error; diff --git a/src/CarsInfrastructure.IntegrationTests/CarsApiSpec.cs b/src/CarsInfrastructure.IntegrationTests/CarsApiSpec.cs index d7648bd2..99c41d28 100644 --- a/src/CarsInfrastructure.IntegrationTests/CarsApiSpec.cs +++ b/src/CarsInfrastructure.IntegrationTests/CarsApiSpec.cs @@ -138,7 +138,7 @@ public async Task WhenTakeCarOffline_ThenReturnsCar() var unavailabilities = (await Api.GetAsync(new SearchAllCarUnavailabilitiesRequest { Id = car.Id - })).Content.Value.Unavailabilities!; + }, req => req.SetJWTBearerToken(login.AccessToken))).Content.Value.Unavailabilities!; unavailabilities.Count.Should().Be(1); unavailabilities[0].CarId.Should().Be(car.Id); @@ -167,7 +167,7 @@ public async Task WhenScheduleMaintenance_ThenReturnsCar() var unavailabilities = (await Api.GetAsync(new SearchAllCarUnavailabilitiesRequest { Id = car.Id - })).Content.Value.Unavailabilities!; + }, req => req.SetJWTBearerToken(login.AccessToken))).Content.Value.Unavailabilities!; unavailabilities.Count.Should().Be(1); unavailabilities[0].CarId.Should().Be(car.Id); diff --git a/src/CarsInfrastructure/Api/Cars/CarsApi.cs b/src/CarsInfrastructure/Api/Cars/CarsApi.cs index c98ec76d..618bbc4d 100644 --- a/src/CarsInfrastructure/Api/Cars/CarsApi.cs +++ b/src/CarsInfrastructure/Api/Cars/CarsApi.cs @@ -1,4 +1,3 @@ -using Application.Interfaces; using Application.Resources.Shared; using CarsApplication; using Infrastructure.Interfaces; @@ -21,16 +20,14 @@ public CarsApi(ICallerContextFactory contextFactory, ICarsApplication carsApplic public async Task Delete(DeleteCarRequest request, CancellationToken cancellationToken) { - var car = await _carsApplication.DeleteCarAsync(_contextFactory.Create(), - MultiTenancyConstants.DefaultOrganizationId, request.Id, + var car = await _carsApplication.DeleteCarAsync(_contextFactory.Create(), request.OrganizationId!, request.Id, cancellationToken); return () => car.HandleApplicationResult(); } public async Task> Get(GetCarRequest request, CancellationToken cancellationToken) { - var car = await _carsApplication.GetCarAsync(_contextFactory.Create(), - MultiTenancyConstants.DefaultOrganizationId, request.Id, + var car = await _carsApplication.GetCarAsync(_contextFactory.Create(), request.OrganizationId!, request.Id, cancellationToken); return () => car.HandleApplicationResult(c => new GetCarResponse { Car = c }); @@ -39,10 +36,8 @@ public async Task> Get(GetCarRequest request, public async Task> Register(RegisterCarRequest request, CancellationToken cancellationToken) { - var car = await _carsApplication.RegisterCarAsync(_contextFactory.Create(), - MultiTenancyConstants.DefaultOrganizationId, request.Make, - request.Model, - request.Year, request.Jurisdiction, request.NumberPlate, cancellationToken); + var car = await _carsApplication.RegisterCarAsync(_contextFactory.Create(), request.OrganizationId!, + request.Make, request.Model, request.Year, request.Jurisdiction, request.NumberPlate, cancellationToken); return () => car.HandleApplicationResult(c => new PostResult(new GetCarResponse { Car = c }, new GetCarRequest { Id = c.Id }.ToUrl())); @@ -51,21 +46,16 @@ public async Task> Register(RegisterCarReques public async Task> ScheduleMaintenance(ScheduleMaintenanceCarRequest request, CancellationToken cancellationToken) { - var car = await _carsApplication.ScheduleMaintenanceCarAsync(_contextFactory.Create(), - MultiTenancyConstants.DefaultOrganizationId, - request.Id, - request.FromUtc, - request.ToUtc, cancellationToken); + var car = await _carsApplication.ScheduleMaintenanceCarAsync(_contextFactory.Create(), request.OrganizationId!, + request.Id, request.FromUtc, request.ToUtc, cancellationToken); return () => car.HandleApplicationResult(c => new GetCarResponse { Car = c }); } public async Task> SearchAll(SearchAllCarsRequest request, CancellationToken cancellationToken) { - var cars = await _carsApplication.SearchAllCarsAsync(_contextFactory.Create(), - MultiTenancyConstants.DefaultOrganizationId, - request.ToSearchOptions(), - request.ToGetOptions(), cancellationToken); + var cars = await _carsApplication.SearchAllCarsAsync(_contextFactory.Create(), request.OrganizationId!, + request.ToSearchOptions(), request.ToGetOptions(), cancellationToken); return () => cars.HandleApplicationResult(c => new SearchAllCarsResponse { Cars = c.Results, Metadata = c.Metadata }); @@ -74,11 +64,8 @@ public async Task> SearchAll(SearchA public async Task> SearchAllAvailable( SearchAllAvailableCarsRequest request, CancellationToken cancellationToken) { - var cars = await _carsApplication.SearchAllAvailableCarsAsync(_contextFactory.Create(), - MultiTenancyConstants.DefaultOrganizationId, - request.FromUtc, - request.ToUtc, request.ToSearchOptions(), - request.ToGetOptions(), cancellationToken); + var cars = await _carsApplication.SearchAllAvailableCarsAsync(_contextFactory.Create(), request.OrganizationId!, + request.FromUtc, request.ToUtc, request.ToSearchOptions(), request.ToGetOptions(), cancellationToken); return () => cars.HandleApplicationResult(c => new SearchAllCarsResponse { Cars = c.Results, Metadata = c.Metadata }); @@ -90,10 +77,7 @@ public async Task unavailabilities.HandleApplicationResult(c => new SearchAllCarUnavailabilitiesResponse @@ -104,9 +88,8 @@ public async Task> TakeOffline(TakeOfflineCarRequest request, CancellationToken cancellationToken) { - var car = await _carsApplication.TakeOfflineCarAsync(_contextFactory.Create(), - MultiTenancyConstants.DefaultOrganizationId, request.Id, - request.FromUtc, request.ToUtc, cancellationToken); + var car = await _carsApplication.TakeOfflineCarAsync(_contextFactory.Create(), request.OrganizationId!, + request.Id, request.FromUtc, request.ToUtc, cancellationToken); return () => car.HandleApplicationResult(c => new GetCarResponse { Car = c }); } } \ No newline at end of file diff --git a/src/Common/Configuration/IConfigurationSettings.cs b/src/Common/Configuration/IConfigurationSettings.cs index 58561973..cc6b9b8a 100644 --- a/src/Common/Configuration/IConfigurationSettings.cs +++ b/src/Common/Configuration/IConfigurationSettings.cs @@ -3,9 +3,15 @@ namespace Common.Configuration; /// /// Configuration settings for the platform, and the current tenancy /// -public interface IConfigurationSettings +public interface IConfigurationSettings : ISettings { + /// + /// Returns settings used by the platform + /// ISettings Platform { get; } + /// + /// Returns settings used by the current tenancy + /// ISettings Tenancy { get; } } \ No newline at end of file diff --git a/src/Domain.Interfaces/Services/IDependencyContainer.cs b/src/Domain.Interfaces/Services/IDependencyContainer.cs index 206a5faa..f287d78c 100644 --- a/src/Domain.Interfaces/Services/IDependencyContainer.cs +++ b/src/Domain.Interfaces/Services/IDependencyContainer.cs @@ -7,4 +7,7 @@ public interface IDependencyContainer { TService Resolve() where TService : notnull; + + TService ResolveForPlatform() + where TService : notnull; } \ No newline at end of file diff --git a/src/Domain.Interfaces/Services/ITenantSettingService.cs b/src/Domain.Interfaces/Services/ITenantSettingService.cs index c91e9651..a0f614fa 100644 --- a/src/Domain.Interfaces/Services/ITenantSettingService.cs +++ b/src/Domain.Interfaces/Services/ITenantSettingService.cs @@ -6,4 +6,6 @@ namespace Domain.Interfaces.Services; public interface ITenantSettingService { string Decrypt(string encryptedValue); + + string Encrypt(string plainText); } \ No newline at end of file diff --git a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs index cc429d87..606693e1 100644 --- a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs +++ b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs @@ -1,5 +1,6 @@ using Application.Interfaces; using Application.Resources.Shared; +using Application.Services.Shared; using Common; using Common.Configuration; using Domain.Common.Identity; @@ -13,6 +14,7 @@ using Moq; using UnitTesting.Common; using Xunit; +using Membership = EndUsersDomain.Membership; namespace EndUsersApplication.UnitTests; @@ -22,6 +24,7 @@ public class EndUsersApplicationSpec private readonly EndUsersApplication _application; private readonly Mock _caller; private readonly Mock _idFactory; + private readonly Mock _organizationsService; private readonly Mock _recorder; private readonly Mock _repository; @@ -30,8 +33,17 @@ public EndUsersApplicationSpec() _recorder = new Mock(); _caller = new Mock(); _idFactory = new Mock(); + var membershipCounter = 0; _idFactory.Setup(idf => idf.Create(It.IsAny())) - .Returns("anid".ToId()); + .Returns((IIdentifiableEntity entity) => + { + if (entity is Membership) + { + return $"amembershipid{membershipCounter++}".ToId(); + } + + return "anid".ToId(); + }); var settings = new Mock(); settings.Setup( s => s.Platform.GetString(EndUsersApplication.PermittedOperatorsSettingName, It.IsAny())) @@ -39,8 +51,20 @@ public EndUsersApplicationSpec() _repository = new Mock(); _repository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) .Returns((EndUserRoot root, CancellationToken _) => Task.FromResult>(root)); + _organizationsService = new Mock(); + _organizationsService.Setup(os => os.CreateOrganizationPrivateAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new Organization + { + Id = "anorganizationid", + CreatedById = "auserid", + Name = "aname" + }); + _application = - new EndUsersApplication(_recorder.Object, _idFactory.Object, settings.Object, _repository.Object); + new EndUsersApplication(_recorder.Object, _idFactory.Object, settings.Object, _organizationsService.Object, + _repository.Object); } [Fact] @@ -85,13 +109,17 @@ public async Task WhenRegisterPerson_ThenRegisters() result.Value.Roles.Should().ContainSingle(role => role == PlatformRoles.Standard.Name); result.Value.Features.Should().ContainSingle(feat => feat == PlatformFeatures.PaidTrial.Name); result.Value.Profile!.Id.Should().Be("anid"); - result.Value.Profile.DefaultOrganisationId.Should().BeNull(); + result.Value.Profile.DefaultOrganizationId.Should().Be("anorganizationid"); result.Value.Profile.Address!.CountryCode.Should().Be(CountryCodes.Default.ToString()); result.Value.Profile.Name.FirstName.Should().Be("afirstname"); result.Value.Profile.Name.LastName.Should().Be("alastname"); result.Value.Profile.DisplayName.Should().Be("afirstname"); result.Value.Profile.EmailAddress.Should().Be("auser@company.com"); result.Value.Profile.Timezone.Should().Be(Timezones.Default.ToString()); + _organizationsService.Verify(os => + os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "afirstname alastname", + OrganizationOwnership.Personal, + It.IsAny())); } [Fact] @@ -111,13 +139,16 @@ public async Task WhenRegisterMachineByAnonymousUser_ThenRegistersWithNoFeatures result.Value.Roles.Should().ContainSingle(role => role == PlatformRoles.Standard.Name); result.Value.Features.Should().ContainSingle(feat => feat == PlatformFeatures.Basic.Name); result.Value.Profile!.Id.Should().Be("anid"); - result.Value.Profile.DefaultOrganisationId.Should().BeNull(); + result.Value.Profile.DefaultOrganizationId.Should().Be("anorganizationid"); result.Value.Profile.Address!.CountryCode.Should().Be(CountryCodes.Default.ToString()); result.Value.Profile.Name.FirstName.Should().Be("aname"); result.Value.Profile.Name.LastName.Should().BeNull(); result.Value.Profile.DisplayName.Should().Be("aname"); result.Value.Profile.EmailAddress.Should().BeNull(); result.Value.Profile.Timezone.Should().Be(Timezones.Default.ToString()); + _organizationsService.Verify(os => + os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "aname", OrganizationOwnership.Personal, + It.IsAny())); } [Fact] @@ -125,6 +156,11 @@ public async Task WhenRegisterMachineByAuthenticatedUser_ThenRegistersWithBasicF { _caller.Setup(cc => cc.IsAuthenticated) .Returns(true); + var adder = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(adder); + adder.Register(Roles.Empty, Features.Empty, EmailAddress.Create("auser@company.com").Value); + adder.AddMembership("anotherorganizationid".ToId(), Roles.Empty, Features.Empty); var result = await _application.RegisterMachineAsync(_caller.Object, "aname", Timezones.Default.ToString(), CountryCodes.Default.ToString(), CancellationToken.None); @@ -137,13 +173,16 @@ public async Task WhenRegisterMachineByAuthenticatedUser_ThenRegistersWithBasicF result.Value.Roles.Should().ContainSingle(role => role == PlatformRoles.Standard.Name); result.Value.Features.Should().ContainSingle(feat => feat == PlatformFeatures.PaidTrial.Name); result.Value.Profile!.Id.Should().Be("anid"); - result.Value.Profile.DefaultOrganisationId.Should().BeNull(); + result.Value.Profile.DefaultOrganizationId.Should().Be("anorganizationid"); result.Value.Profile.Address!.CountryCode.Should().Be(CountryCodes.Default.ToString()); result.Value.Profile.Name.FirstName.Should().Be("aname"); result.Value.Profile.Name.LastName.Should().BeNull(); result.Value.Profile.DisplayName.Should().Be("aname"); result.Value.Profile.EmailAddress.Should().BeNull(); result.Value.Profile.Timezone.Should().Be(Timezones.Default.ToString()); + _organizationsService.Verify(os => + os.CreateOrganizationPrivateAsync(_caller.Object, "anid", "aname", OrganizationOwnership.Personal, + It.IsAny())); } #if TESTINGONLY @@ -228,4 +267,83 @@ public async Task WhenFindPersonByEmailAsyncAndExists_ThenReturns() result.Should().BeSuccess(); result.Value.Value.Id.Should().Be("anid"); } + + [Fact] + public async Task WhenGetMembershipsAndNotRegisteredOrMemberAsync_ThenReturnsUser() + { + var user = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(user); + + var result = await _application.GetMembershipsAsync(_caller.Object, "anid", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Id.Should().Be("anid"); + result.Value.Access.Should().Be(EndUserAccess.Enabled); + result.Value.Status.Should().Be(EndUserStatus.Unregistered); + result.Value.Classification.Should().Be(EndUserClassification.Person); + result.Value.Roles.Should().BeEmpty(); + result.Value.Features.Should().BeEmpty(); + result.Value.Memberships.Count.Should().Be(0); + } + + [Fact] + public async Task WhenGetMembershipsAsync_ThenReturnsUser() + { + var user = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + user.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value, + EmailAddress.Create("auser@company.com").Value); + user.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, + Features.Create(TenantFeatures.PaidTrial).Value); + _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(user); + + var result = await _application.GetMembershipsAsync(_caller.Object, "anid", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Id.Should().Be("anid"); + result.Value.Access.Should().Be(EndUserAccess.Enabled); + result.Value.Status.Should().Be(EndUserStatus.Registered); + result.Value.Classification.Should().Be(EndUserClassification.Person); + result.Value.Roles.Should().ContainSingle(role => role == PlatformRoles.Standard.Name); + result.Value.Features.Should().ContainSingle(feat => feat == PlatformFeatures.Basic.Name); + result.Value.Memberships.Count.Should().Be(1); + result.Value.Memberships[0].IsDefault.Should().BeTrue(); + result.Value.Memberships[0].OrganizationId.Should().Be("anorganizationid"); + result.Value.Memberships[0].Roles.Should().ContainSingle(role => role == TenantRoles.Member.Name); + result.Value.Memberships[0].Features.Should().ContainSingle(feat => feat == TenantFeatures.PaidTrial.Name); + } + + [Fact] + public async Task WhenCreateMembershipForCallerAsyncAndUserNoExist_ThenReturnsError() + { + _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = + await _application.CreateMembershipForCallerAsync(_caller.Object, "anorganizationid", + CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task WhenCreateMembershipForCallerAsync_ThenAddsMembership() + { + var user = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + user.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value, + EmailAddress.Create("auser@company.com").Value); + _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(user); + + var result = + await _application.CreateMembershipForCallerAsync(_caller.Object, "anorganizationid", + CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.IsDefault.Should().BeTrue(); + result.Value.OrganizationId.Should().Be("anorganizationid"); + result.Value.Roles.Should().ContainSingle(role => role == TenantRoles.Member.Name); + result.Value.Features.Should().ContainSingle(feat => feat == TenantFeatures.Basic.Name); + } } \ No newline at end of file diff --git a/src/EndUsersApplication/EndUsersApplication.cs b/src/EndUsersApplication/EndUsersApplication.cs index b825a51f..77d26f58 100644 --- a/src/EndUsersApplication/EndUsersApplication.cs +++ b/src/EndUsersApplication/EndUsersApplication.cs @@ -1,6 +1,7 @@ using Application.Common.Extensions; using Application.Interfaces; using Application.Resources.Shared; +using Application.Services.Shared; using Common; using Common.Configuration; using Common.Extensions; @@ -19,16 +20,18 @@ public class EndUsersApplication : IEndUsersApplication internal const string PermittedOperatorsSettingName = "Hosts:EndUsersApi:Authorization:OperatorWhitelist"; private static readonly char[] PermittedOperatorsDelimiters = { ';', ',', ' ' }; private readonly IIdentifierFactory _idFactory; + private readonly IOrganizationsService _organizationsService; private readonly IRecorder _recorder; private readonly IEndUserRepository _repository; private readonly IConfigurationSettings _settings; public EndUsersApplication(IRecorder recorder, IIdentifierFactory idFactory, IConfigurationSettings settings, - IEndUserRepository repository) + IOrganizationsService organizationsService, IEndUserRepository repository) { _recorder = recorder; _idFactory = idFactory; _settings = settings; + _organizationsService = organizationsService; _repository = repository; } @@ -73,39 +76,62 @@ public async Task> RegisterMachineAsync(ICaller return created.Error; } - var user = created.Value; + var machine = created.Value; //TODO: get/create the profile for this machine (with first,tz,cc) - it may already be existent or not - var (platformRoles, platformFeatures, organizationRoles, organizationFeatures) = - user.GetInitialRolesAndFeatures(UserClassification.Machine, context.IsAuthenticated, + var (platformRoles, platformFeatures, tenantRoles, tenantFeatures) = + EndUserRoot.GetInitialRolesAndFeatures(UserClassification.Machine, context.IsAuthenticated, Optional.None, Optional>.None); - var registered = user.Register(platformRoles, platformFeatures, Optional.None); + var registered = machine.Register(platformRoles, platformFeatures, Optional.None); if (!registered.IsSuccessful) { return registered.Error; } + var defaultOrganization = + await _organizationsService.CreateOrganizationPrivateAsync(context, machine.Id, name, + OrganizationOwnership.Personal, cancellationToken); + if (!defaultOrganization.IsSuccessful) + { + return defaultOrganization.Error; + } + + var defaultOrganizationId = defaultOrganization.Value.Id.ToId(); + var selfEnrolled = machine.AddMembership(defaultOrganizationId, tenantRoles, + tenantFeatures); + if (!selfEnrolled.IsSuccessful) + { + return selfEnrolled.Error; + } + if (context.IsAuthenticated) { - var enrolled = user.AddMembership(MultiTenancyConstants.DefaultOrganizationId.ToId(), organizationRoles, - organizationFeatures); - if (!enrolled.IsSuccessful) + var adder = await _repository.LoadAsync(context.ToCallerId(), cancellationToken); + if (!adder.IsSuccessful) { - return enrolled.Error; + return adder.Error; + } + + var adderDefaultOrganizationId = adder.Value.Memberships.DefaultMembership.OrganizationId; + var adderEnrolled = machine.AddMembership(adderDefaultOrganizationId, tenantRoles, + tenantFeatures); + if (!adderEnrolled.IsSuccessful) + { + return adderEnrolled.Error; } } - var saved = await _repository.SaveAsync(user, cancellationToken); + var saved = await _repository.SaveAsync(machine, cancellationToken); if (!saved.IsSuccessful) { return saved.Error; } - _recorder.TraceInformation(context.ToCall(), "Registered machine: {Id}", user.Id); + _recorder.TraceInformation(context.ToCall(), "Registered machine: {Id}", machine.Id); _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.MachineRegistered); - return user.ToRegisteredUser(null, name, null, Timezones.FindOrDefault(timezone), + return machine.ToRegisteredUser(defaultOrganizationId, null, name, null, Timezones.FindOrDefault(timezone), CountryCodes.FindOrDefault(countryCode)); } @@ -137,8 +163,8 @@ public async Task> RegisterPersonAsync(ICallerC } var permittedOperators = GetPermittedOperators(); - var (platformRoles, platformFeatures, organizationRoles, organizationFeatures) = - user.GetInitialRolesAndFeatures(UserClassification.Person, context.IsAuthenticated, username.Value, + var (platformRoles, platformFeatures, tenantRoles, tenantFeatures) = + EndUserRoot.GetInitialRolesAndFeatures(UserClassification.Person, context.IsAuthenticated, username.Value, permittedOperators); var registered = user.Register(platformRoles, platformFeatures, username.Value); if (!registered.IsSuccessful) @@ -146,8 +172,24 @@ public async Task> RegisterPersonAsync(ICallerC return registered.Error; } - var enrolled = user.AddMembership(MultiTenancyConstants.DefaultOrganizationId.ToId(), organizationRoles, - organizationFeatures); + var organizationName = Domain.Shared.PersonName.Create(firstName, lastName); + if (!organizationName.IsSuccessful) + { + return organizationName.Error; + } + + var defaultOrganization = + await _organizationsService.CreateOrganizationPrivateAsync(context, user.Id, + organizationName.Value.FullName, OrganizationOwnership.Personal, + cancellationToken); + if (!defaultOrganization.IsSuccessful) + { + return defaultOrganization.Error; + } + + var defaultOrganizationId = defaultOrganization.Value.Id.ToId(); + var enrolled = user.AddMembership(defaultOrganizationId, tenantRoles, + tenantFeatures); if (!enrolled.IsSuccessful) { return enrolled.Error; @@ -162,13 +204,52 @@ public async Task> RegisterPersonAsync(ICallerC _recorder.TraceInformation(context.ToCall(), "Registered user: {Id}", user.Id); _recorder.AuditAgainst(context.ToCall(), user.Id, Audits.EndUsersApplication_User_Registered_TermsAccepted, - "UserAccount {Id} accepted their terms and conditions", user.Id); + "EndUser {Id} accepted their terms and conditions", user.Id); _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.PersonRegistrationCreated); - return user.ToRegisteredUser(emailAddress, firstName, lastName, Timezones.FindOrDefault(timezone), + return user.ToRegisteredUser(defaultOrganizationId, emailAddress, firstName, lastName, + Timezones.FindOrDefault(timezone), CountryCodes.FindOrDefault(countryCode)); } + public async Task> CreateMembershipForCallerAsync(ICallerContext context, + string organizationId, CancellationToken cancellationToken) + { + var retrieved = await _repository.LoadAsync(context.ToCallerId(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var user = retrieved.Value; + var (_, _, tenantRoles, tenantFeatures) = + EndUserRoot.GetInitialRolesAndFeatures(UserClassification.Person, context.IsAuthenticated, + Optional.None, + Optional>.None); + var membered = user.AddMembership(organizationId.ToId(), tenantRoles, tenantFeatures); + if (!membered.IsSuccessful) + { + return membered.Error; + } + + var saved = await _repository.SaveAsync(user, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + _recorder.TraceInformation(context.ToCall(), "EndUser {Id} has become a member of organisation {Organisation}", + user.Id, organizationId); + + var membership = saved.Value.FindMembership(organizationId.ToId()); + if (!membership.HasValue) + { + return Error.EntityNotFound(Resources.EndUsersApplication_MembershipNotFound); + } + + return membership.Value.ToMembership(); + } + public async Task, Error>> FindPersonByEmailAsync(ICallerContext context, string emailAddress, CancellationToken cancellationToken) { @@ -311,7 +392,20 @@ private Optional> GetPermittedOperators() internal static class EndUserConversionExtensions { - public static RegisteredEndUser ToRegisteredUser(this EndUserRoot user, string? emailAddress, string firstName, + public static Membership ToMembership(this EndUsersDomain.Membership ms) + { + return new Membership + { + Id = ms.Id, + IsDefault = ms.IsDefault, + OrganizationId = ms.OrganizationId.Value, + Features = ms.Features.ToList(), + Roles = ms.Roles.ToList() + }; + } + + public static RegisteredEndUser ToRegisteredUser(this EndUserRoot user, Identifier defaultOrganizationId, + string? emailAddress, string firstName, string? lastName, TimezoneIANA timezone, CountryCodeIso3166 countryCode) { var endUser = ToUser(user); @@ -329,7 +423,7 @@ public static RegisteredEndUser ToRegisteredUser(this EndUserRoot user, string? PhoneNumber = null, Timezone = timezone.ToString(), Id = user.Id, - DefaultOrganisationId = null + DefaultOrganizationId = defaultOrganizationId }; return registeredUser; @@ -352,13 +446,7 @@ public static EndUserWithMemberships ToUserWithMemberships(this EndUserRoot user { var endUser = ToUser(user); var withMemberships = endUser.Convert(); - withMemberships.Memberships = user.Memberships.Select(ms => new Membership - { - Id = ms.Id, - OrganizationId = ms.OrganizationId.Value, - Features = ms.Features.ToList(), - Roles = ms.Roles.ToList() - }).ToList(); + withMemberships.Memberships = user.Memberships.Select(ms => ms.ToMembership()).ToList(); return withMemberships; } diff --git a/src/EndUsersApplication/EndUsersApplication.csproj b/src/EndUsersApplication/EndUsersApplication.csproj index c4cd30e7..fd08e8d6 100644 --- a/src/EndUsersApplication/EndUsersApplication.csproj +++ b/src/EndUsersApplication/EndUsersApplication.csproj @@ -14,6 +14,7 @@ + diff --git a/src/EndUsersApplication/IEndUsersApplication.cs b/src/EndUsersApplication/IEndUsersApplication.cs index dbbe4fb2..006ec0a0 100644 --- a/src/EndUsersApplication/IEndUsersApplication.cs +++ b/src/EndUsersApplication/IEndUsersApplication.cs @@ -13,6 +13,12 @@ Task> AssignTenantRolesAsync(ICallerContex string id, List roles, CancellationToken cancellationToken); + Task> CreateMembershipForCallerAsync(ICallerContext context, string organizationId, + CancellationToken cancellationToken); + + Task, Error>> FindPersonByEmailAsync(ICallerContext context, string emailAddress, + CancellationToken cancellationToken); + Task> GetMembershipsAsync(ICallerContext context, string id, CancellationToken cancellationToken); @@ -24,7 +30,4 @@ Task> RegisterMachineAsync(ICallerContext conte Task> RegisterPersonAsync(ICallerContext context, string emailAddress, string firstName, string? lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken); - - Task, Error>> FindPersonByEmailAsync(ICallerContext context, string emailAddress, - CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/EndUsersApplication/Persistence/IEndUserRepository.cs b/src/EndUsersApplication/Persistence/IEndUserRepository.cs index 58b8fe6f..04241851 100644 --- a/src/EndUsersApplication/Persistence/IEndUserRepository.cs +++ b/src/EndUsersApplication/Persistence/IEndUserRepository.cs @@ -10,7 +10,7 @@ public interface IEndUserRepository : IApplicationRepository { Task> LoadAsync(Identifier id, CancellationToken cancellationToken); - Task> SaveAsync(EndUserRoot endUser, CancellationToken cancellationToken); + Task> SaveAsync(EndUserRoot user, CancellationToken cancellationToken); Task, Error>> FindByEmailAddressAsync(EmailAddress emailAddress, CancellationToken cancellationToken); diff --git a/src/EndUsersApplication/Resources.Designer.cs b/src/EndUsersApplication/Resources.Designer.cs index 667d3740..8748c95b 100644 --- a/src/EndUsersApplication/Resources.Designer.cs +++ b/src/EndUsersApplication/Resources.Designer.cs @@ -59,6 +59,15 @@ internal Resources() { } } + /// + /// Looks up a localized string similar to The membership could not be found. + /// + internal static string EndUsersApplication_MembershipNotFound { + get { + return ResourceManager.GetString("EndUsersApplication_MembershipNotFound", resourceCulture); + } + } + /// /// Looks up a localized string similar to This user must accept the terms of service. /// diff --git a/src/EndUsersApplication/Resources.resx b/src/EndUsersApplication/Resources.resx index 21a7644d..29c26cf0 100644 --- a/src/EndUsersApplication/Resources.resx +++ b/src/EndUsersApplication/Resources.resx @@ -27,4 +27,7 @@ This user must accept the terms of service + + The membership could not be found + \ No newline at end of file diff --git a/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs b/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs index a0cc3c33..e0ac6640 100644 --- a/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs +++ b/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs @@ -118,7 +118,7 @@ public void WhenAddMembershipAndAlreadyMember_ThenReturns() } [Fact] - public void WhenAddMembership_ThenAddsMembershipRolesAndFeatures() + public void WhenAddMembership_ThenAddsMembershipAsDefaultWithRolesAndFeatures() { _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, Features.Create(PlatformFeatures.Basic.Name).Value, @@ -130,13 +130,15 @@ public void WhenAddMembership_ThenAddsMembershipRolesAndFeatures() result.Should().BeSuccess(); _user.Memberships.Should().ContainSingle(ms => - ms.OrganizationId.Value == "anorganizationid" && ms.IsDefault && ms.Roles == roles + ms.OrganizationId.Value == "anorganizationid" + && ms.IsDefault + && ms.Roles == roles && ms.Features == features); _user.Events.Last().Should().BeOfType(); } [Fact] - public void WhenAddMembershipAndNextMembership_ThenChangesNextToDefaultMembership() + public void WhenAddMembershipAndHasMembership_ThenChangesNextToDefaultMembership() { _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, Features.Create(PlatformFeatures.Basic.Name).Value, @@ -149,10 +151,14 @@ public void WhenAddMembershipAndNextMembership_ThenChangesNextToDefaultMembershi result.Should().BeSuccess(); _user.Memberships.Should().Contain(ms => - ms.OrganizationId.Value == "anorganizationid1" && !ms.IsDefault && ms.Roles == roles + ms.OrganizationId.Value == "anorganizationid1" + && !ms.IsDefault + && ms.Roles == roles && ms.Features == features); _user.Memberships.Should().Contain(ms => - ms.OrganizationId.Value == "anorganizationid2" && ms.IsDefault && ms.Roles == roles + ms.OrganizationId.Value == "anorganizationid2" + && ms.IsDefault + && ms.Roles == roles && ms.Features == features); _user.Events.Last().Should().BeOfType(); } diff --git a/src/EndUsersDomain/EndUserRoot.cs b/src/EndUsersDomain/EndUserRoot.cs index 452b1805..b7184314 100644 --- a/src/EndUsersDomain/EndUserRoot.cs +++ b/src/EndUsersDomain/EndUserRoot.cs @@ -417,7 +417,7 @@ public Result AssignPlatformRoles(EndUserRoot assigner, Roles platformRol /// /// EXTEND: change this to assign initial roles and features for persons and machines /// - public (Roles PlatformRoles, Features PlatformFeatures, Roles TenantRoles, Features TenantFeatures) + public static (Roles PlatformRoles, Features PlatformFeatures, Roles TenantRoles, Features TenantFeatures) GetInitialRolesAndFeatures(UserClassification classification, bool isAuthenticated, Optional username, Optional> permittedOperators) { @@ -482,4 +482,9 @@ private static bool IsOrganizationOwner(EndUserRoot assigner, Identifier organiz return retrieved.Value.Roles.HasRole(TenantRoles.Owner); } + + public Optional FindMembership(Identifier organizationId) + { + return Memberships.FindByOrganizationId(organizationId); + } } \ No newline at end of file diff --git a/src/EndUsersDomain/Membership.cs b/src/EndUsersDomain/Membership.cs index 3797bb70..ef1578cd 100644 --- a/src/EndUsersDomain/Membership.cs +++ b/src/EndUsersDomain/Membership.cs @@ -95,7 +95,6 @@ protected override Result OnStateChanged(IDomainEvent @event) } Features = feature.Value; - return Result.Ok; } diff --git a/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs b/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs index 6641fb4d..b6d8e383 100644 --- a/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs +++ b/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs @@ -18,7 +18,8 @@ public EndUsersApi(ICallerContextFactory contextFactory, IEndUsersApplication en _endUsersApplication = endUsersApplication; } - public async Task> Deliver(AssignPlatformRolesRequest request, + public async Task> AssignPlatformRoles( + AssignPlatformRolesRequest request, CancellationToken cancellationToken) { var user = diff --git a/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs b/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs index cb35928f..d3a6d3e1 100644 --- a/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs +++ b/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs @@ -25,19 +25,28 @@ public async Task> GetPersonAsync(ICallerContext caller, return await _endUsersApplication.GetPersonAsync(caller, id, cancellationToken); } - public async Task, Error>> FindPersonByEmailAsync(ICallerContext caller, + public async Task> CreateMembershipForCallerPrivateAsync(ICallerContext caller, + string organizationId, + CancellationToken cancellationToken) + { + return await _endUsersApplication.CreateMembershipForCallerAsync(caller, organizationId, cancellationToken); + } + + public async Task, Error>> FindPersonByEmailPrivateAsync(ICallerContext caller, string emailAddress, CancellationToken cancellationToken) { return await _endUsersApplication.FindPersonByEmailAsync(caller, emailAddress, cancellationToken); } - public async Task> GetMembershipsAsync(ICallerContext caller, string id, + public async Task> GetMembershipsPrivateAsync(ICallerContext caller, + string id, CancellationToken cancellationToken) { return await _endUsersApplication.GetMembershipsAsync(caller, id, cancellationToken); } - public async Task> RegisterPersonAsync(ICallerContext caller, string emailAddress, + public async Task> RegisterPersonPrivateAsync(ICallerContext caller, + string emailAddress, string firstName, string? lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken) { @@ -45,7 +54,7 @@ public async Task> RegisterPersonAsync(ICallerC countryCode, termsAndConditionsAccepted, cancellationToken); } - public async Task> RegisterMachineAsync(ICallerContext caller, string name, + public async Task> RegisterMachinePrivateAsync(ICallerContext caller, string name, string? timezone, string? countryCode, CancellationToken cancellationToken) { diff --git a/src/EndUsersInfrastructure/EndUsersModule.cs b/src/EndUsersInfrastructure/EndUsersModule.cs index ac41ed08..aadf2136 100644 --- a/src/EndUsersInfrastructure/EndUsersModule.cs +++ b/src/EndUsersInfrastructure/EndUsersModule.cs @@ -2,6 +2,8 @@ using Application.Persistence.Interfaces; using Application.Services.Shared; using Common; +using Common.Configuration; +using Domain.Common.Identity; using Domain.Interfaces; using EndUsersApplication; using EndUsersApplication.Persistence; @@ -27,7 +29,8 @@ public class EndUsersModule : ISubDomainModule public Dictionary AggregatePrefixes => new() { - { typeof(EndUserRoot), "user" } + { typeof(EndUserRoot), "user" }, + { typeof(Membership), "mship" } }; public Action> ConfigureMiddleware @@ -41,7 +44,11 @@ public Action RegisterServices { return (_, services) => { - services.RegisterUnshared(); + services.RegisterUnshared(c => + new EndUsersApplication.EndUsersApplication(c.ResolveForUnshared(), + c.ResolveForUnshared(), c.ResolveForPlatform(), + c.ResolveForUnshared(), + c.ResolveForUnshared())); services.RegisterUnshared(c => new EndUserRepository( c.ResolveForUnshared(), c.ResolveForUnshared(), diff --git a/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs b/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs index f78806bf..2bedbc52 100644 --- a/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs +++ b/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs @@ -51,7 +51,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven }, cancellationToken); case Events.MembershipAdded e: - return await _memberships.HandleUpdateAsync(e.RootId.ToId(), dto => + return await _memberships.HandleCreateAsync(e.MembershipId.ToId(), dto => { dto.IsDefault = e.IsDefault; dto.UserId = e.RootId; diff --git a/src/IdentityApplication.UnitTests/APIKeysApplicationSpec.cs b/src/IdentityApplication.UnitTests/APIKeysApplicationSpec.cs index 505395d2..43927667 100644 --- a/src/IdentityApplication.UnitTests/APIKeysApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/APIKeysApplicationSpec.cs @@ -102,14 +102,16 @@ public async Task WhenFindUserForAPIKeyAsyncAndUserNotExist_ThenReturnsNone() _repository.Setup(rep => rep.FindByAPIKeyTokenAsync(It.IsAny(), It.IsAny())) .Returns(Task.FromResult, Error>>(apiKey.ToOptional())); _endUsersService.Setup(eus => - eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) .Returns(Task.FromResult>(Error.EntityNotFound())); var result = await _application.FindMembershipsForAPIKeyAsync(_caller.Object, "anapikey", CancellationToken.None); result.Value.Should().BeNone(); - _endUsersService.Verify(eus => eus.GetMembershipsAsync(_caller.Object, "auserid", CancellationToken.None)); + _endUsersService.Verify( + eus => eus.GetMembershipsPrivateAsync(_caller.Object, "auserid", CancellationToken.None)); } [Fact] @@ -123,14 +125,16 @@ public async Task WhenFindUserForAPIKeyAsync_ThenReturnsApiKey() Id = "auserid" }; _endUsersService.Setup(eus => - eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) .Returns(Task.FromResult>(user)); var result = await _application.FindMembershipsForAPIKeyAsync(_caller.Object, "anapikey", CancellationToken.None); result.Value.Value.Id.Should().Be("auserid"); - _endUsersService.Verify(eus => eus.GetMembershipsAsync(_caller.Object, "auserid", CancellationToken.None)); + _endUsersService.Verify( + eus => eus.GetMembershipsPrivateAsync(_caller.Object, "auserid", CancellationToken.None)); } #if TESTINGONLY diff --git a/src/IdentityApplication.UnitTests/AuthTokensApplicationSpec.cs b/src/IdentityApplication.UnitTests/AuthTokensApplicationSpec.cs index 3f8ccf9c..d22cfd4a 100644 --- a/src/IdentityApplication.UnitTests/AuthTokensApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/AuthTokensApplicationSpec.cs @@ -133,7 +133,8 @@ public async Task WhenRefreshTokenAsyncAndTokensExist_ThenReturnsRefreshedTokens _repository.Setup(rep => rep.FindByRefreshTokenAsync(It.IsAny(), It.IsAny())) .Returns(Task.FromResult, Error>>(authTokens.ToOptional())); _endUsersService.Setup(eus => - eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) .Returns(Task.FromResult>(user)); _jwtTokensService.Setup(jts => jts.IssueTokensAsync(It.IsAny())) .Returns(Task.FromResult>( diff --git a/src/IdentityApplication.UnitTests/MachineCredentialsApplicationSpec.cs b/src/IdentityApplication.UnitTests/MachineCredentialsApplicationSpec.cs index 2a5c3e08..7d359c14 100644 --- a/src/IdentityApplication.UnitTests/MachineCredentialsApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/MachineCredentialsApplicationSpec.cs @@ -36,7 +36,7 @@ public async Task WhenRegisterMachine_ThenRegistersMachine() { Id = "amachineid" }; - _endUsersService.Setup(eus => eus.RegisterMachineAsync(It.IsAny(), It.IsAny(), + _endUsersService.Setup(eus => eus.RegisterMachinePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.FromResult>(user)); var expiresOn = DateTime.UtcNow.AddDays(1); diff --git a/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs b/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs index 4a39093a..99008f12 100644 --- a/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs @@ -101,7 +101,8 @@ public async Task WhenAuthenticateAsyncAndUnknownUser_ThenReturnsError() _repository.Setup(rep => rep.FindCredentialsByUsernameAsync(It.IsAny(), It.IsAny())) .Returns(Task.FromResult, Error>>(credential.ToOptional())); _endUsersService.Setup(eus => - eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) .Returns(Task.FromResult>(Error.EntityNotFound())); var result = @@ -117,7 +118,8 @@ public async Task WhenAuthenticateAsyncAndEndUserIsNotRegistered_ThenReturnsErro _repository.Setup(rep => rep.FindCredentialsByUsernameAsync(It.IsAny(), It.IsAny())) .Returns(Task.FromResult, Error>>(credential.ToOptional())); _endUsersService.Setup(eus => - eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) .Returns(Task.FromResult>(new EndUserWithMemberships { Id = "anid", @@ -137,7 +139,8 @@ public async Task WhenAuthenticateAsyncAndEndUserIsSuspended_ThenReturnsError() _repository.Setup(rep => rep.FindCredentialsByUsernameAsync(It.IsAny(), It.IsAny())) .Returns(Task.FromResult, Error>>(credential.ToOptional())); _endUsersService.Setup(eus => - eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) .Returns(Task.FromResult>(new EndUserWithMemberships { Id = "auserid", @@ -168,7 +171,8 @@ public async Task WhenAuthenticateAsyncAndCredentialsIsLocked_ThenReturnsError() _repository.Setup(rep => rep.FindCredentialsByUsernameAsync(It.IsAny(), It.IsAny())) .Returns(Task.FromResult, Error>>(credential.ToOptional())); _endUsersService.Setup(eus => - eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) .Returns(Task.FromResult>(new EndUserWithMemberships { Id = "auserid", @@ -194,7 +198,8 @@ public async Task WhenAuthenticateAsyncAndWrongPassword_ThenReturnsError() _repository.Setup(rep => rep.FindCredentialsByUsernameAsync(It.IsAny(), It.IsAny())) .Returns(Task.FromResult, Error>>(credential.ToOptional())); _endUsersService.Setup(eus => - eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) .Returns(Task.FromResult>(new EndUserWithMemberships { Id = "auserid", @@ -221,7 +226,8 @@ public async Task WhenAuthenticateAsyncAndCredentialsNotYetVerified_ThenReturnsE _repository.Setup(rep => rep.FindCredentialsByUsernameAsync(It.IsAny(), It.IsAny())) .Returns(Task.FromResult, Error>>(credential.ToOptional())); _endUsersService.Setup(eus => - eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) .Returns(Task.FromResult>(new EndUserWithMemberships { Id = "auserid", @@ -253,7 +259,8 @@ public async Task WhenAuthenticateAsyncWithCorrectPassword_ThenReturnsError() Access = EndUserAccess.Enabled }; _endUsersService.Setup(eus => - eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) .Returns(Task.FromResult>(user)); var expiresOn = DateTime.UtcNow; _authTokensService.Setup(jts => @@ -285,7 +292,7 @@ public async Task WhenRegisterPersonUserAccountAndAlreadyExists_ThenDoesNothing( { Id = "auserid" }; - _endUsersService.Setup(uas => uas.RegisterPersonAsync(It.IsAny(), + _endUsersService.Setup(uas => uas.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.FromResult>(endUser)); @@ -299,7 +306,7 @@ public async Task WhenRegisterPersonUserAccountAndAlreadyExists_ThenDoesNothing( result.Value.User.Should().Be(endUser); _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never); - _endUsersService.Verify(uas => uas.RegisterPersonAsync(_caller.Object, + _endUsersService.Verify(uas => uas.RegisterPersonPrivateAsync(_caller.Object, "auser@company.com", "afirstname", "alastname", "atimezone", "acountrycode", true, It.IsAny())); } @@ -321,7 +328,7 @@ public async Task WhenRegisterPersonUserAccountAndNotExists_ThenCreatesAndSendsC EmailAddress = "auser@company.com" } }; - _endUsersService.Setup(uas => uas.RegisterPersonAsync(It.IsAny(), + _endUsersService.Setup(uas => uas.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.FromResult>(registeredAccount)); @@ -345,7 +352,7 @@ public async Task WhenRegisterPersonUserAccountAndNotExists_ThenCreatesAndSendsC _notificationsService.Verify(ns => ns.NotifyPasswordRegistrationConfirmationAsync(_caller.Object, "auser@company.com", "adisplayname", "averificationtoken", It.IsAny())); - _endUsersService.Verify(eus => eus.RegisterPersonAsync(_caller.Object, + _endUsersService.Verify(eus => eus.RegisterPersonPrivateAsync(_caller.Object, "auser@company.com", "afirstname", "alastname", "atimezone", "acountrycode", true, It.IsAny())); } diff --git a/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs b/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs index 1e25fea4..0e6829c2 100644 --- a/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs @@ -48,7 +48,7 @@ public async Task WhenAuthenticateAndNoProvider_ThenReturnsError() result.Should().BeError(ErrorCode.NotAuthenticated); _endUsersService.Verify( - eus => eus.FindPersonByEmailAsync(It.IsAny(), It.IsAny(), + eus => eus.FindPersonByEmailPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } @@ -65,7 +65,7 @@ public async Task WhenAuthenticateAndProviderErrors_ThenReturnsError() result.Should().BeError(ErrorCode.Unexpected, "amessage"); _endUsersService.Verify( - eus => eus.FindPersonByEmailAsync(It.IsAny(), It.IsAny(), + eus => eus.FindPersonByEmailPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } @@ -84,11 +84,12 @@ public async Task WhenAuthenticateAndPersonExistsButNotRegisteredYet_ThenIssuesT Id = "anexistinguserid" }; _endUsersService.Setup(eus => - eus.FindPersonByEmailAsync(It.IsAny(), It.IsAny(), + eus.FindPersonByEmailPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(endUser.ToOptional()); _endUsersService.Setup(eus => - eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) .ReturnsAsync(new EndUserWithMemberships { Id = "amembershipsuserid", @@ -105,15 +106,15 @@ public async Task WhenAuthenticateAndPersonExistsButNotRegisteredYet_ThenIssuesT result.Should().BeError(ErrorCode.NotAuthenticated); _endUsersService.Verify(eus => - eus.FindPersonByEmailAsync(_caller.Object, "auser@company.com", It.IsAny())); - _endUsersService.Verify(eus => eus.RegisterPersonAsync(It.IsAny(), It.IsAny(), + eus.FindPersonByEmailPrivateAsync(_caller.Object, "auser@company.com", It.IsAny())); + _endUsersService.Verify(eus => eus.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _ssoProvidersService.Verify( sps => sps.SaveUserInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _endUsersService.Verify(eus => - eus.GetMembershipsAsync(_caller.Object, "anexistinguserid", It.IsAny())); + eus.GetMembershipsPrivateAsync(_caller.Object, "anexistinguserid", It.IsAny())); _authTokensService.Verify( ats => ats.IssueTokensAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -134,11 +135,12 @@ public async Task WhenAuthenticateAndPersonExistsButSuspended_ThenIssuesToken() Id = "anexistinguserid" }; _endUsersService.Setup(eus => - eus.FindPersonByEmailAsync(It.IsAny(), It.IsAny(), + eus.FindPersonByEmailPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(endUser.ToOptional()); _endUsersService.Setup(eus => - eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) .ReturnsAsync(new EndUserWithMemberships { Id = "amembershipsuserid", @@ -155,15 +157,15 @@ public async Task WhenAuthenticateAndPersonExistsButSuspended_ThenIssuesToken() result.Should().BeError(ErrorCode.EntityExists, Resources.SingleSignOnApplication_AccountSuspended); _endUsersService.Verify(eus => - eus.FindPersonByEmailAsync(_caller.Object, "auser@company.com", It.IsAny())); - _endUsersService.Verify(eus => eus.RegisterPersonAsync(It.IsAny(), It.IsAny(), + eus.FindPersonByEmailPrivateAsync(_caller.Object, "auser@company.com", It.IsAny())); + _endUsersService.Verify(eus => eus.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _ssoProvidersService.Verify( sps => sps.SaveUserInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _endUsersService.Verify(eus => - eus.GetMembershipsAsync(_caller.Object, "anexistinguserid", It.IsAny())); + eus.GetMembershipsPrivateAsync(_caller.Object, "anexistinguserid", It.IsAny())); _authTokensService.Verify( ats => ats.IssueTokensAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -180,10 +182,10 @@ public async Task WhenAuthenticateAndPersonNotExists_ThenRegistersPersonAndIssue It.IsAny())) .ReturnsAsync(userInfo); _endUsersService.Setup(eus => - eus.FindPersonByEmailAsync(It.IsAny(), It.IsAny(), + eus.FindPersonByEmailPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Optional.None()); - _endUsersService.Setup(eus => eus.RegisterPersonAsync(It.IsAny(), It.IsAny(), + _endUsersService.Setup(eus => eus.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new RegisteredEndUser @@ -191,7 +193,8 @@ public async Task WhenAuthenticateAndPersonNotExists_ThenRegistersPersonAndIssue Id = "aregistereduserid" }); _endUsersService.Setup(eus => - eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) .ReturnsAsync(new EndUserWithMemberships { Id = "amembershipsuserid", @@ -212,13 +215,14 @@ public async Task WhenAuthenticateAndPersonNotExists_ThenRegistersPersonAndIssue result.Value.RefreshToken.Value.Should().Be("arefreshtoken"); result.Value.RefreshToken.ExpiresOn.Should().Be(expiresOn); _endUsersService.Verify(eus => - eus.FindPersonByEmailAsync(_caller.Object, "auser@company.com", It.IsAny())); - _endUsersService.Verify(eus => eus.RegisterPersonAsync(_caller.Object, "auser@company.com", "afirstname", null, + eus.FindPersonByEmailPrivateAsync(_caller.Object, "auser@company.com", It.IsAny())); + _endUsersService.Verify(eus => eus.RegisterPersonPrivateAsync(_caller.Object, "auser@company.com", "afirstname", + null, Timezones.Default.ToString(), CountryCodes.Default.ToString(), true, It.IsAny())); _ssoProvidersService.Verify(sps => sps.SaveUserInfoAsync("aprovidername", "aregistereduserid".ToId(), It.Is(ui => ui == userInfo), It.IsAny())); _endUsersService.Verify(eus => - eus.GetMembershipsAsync(_caller.Object, "aregistereduserid", It.IsAny())); + eus.GetMembershipsPrivateAsync(_caller.Object, "aregistereduserid", It.IsAny())); _authTokensService.Verify(ats => ats.IssueTokensAsync(_caller.Object, It.Is(eu => eu.Id == "amembershipsuserid" ), It.IsAny())); @@ -239,11 +243,12 @@ public async Task WhenAuthenticateAndPersonExists_ThenIssuesToken() Id = "anexistinguserid" }; _endUsersService.Setup(eus => - eus.FindPersonByEmailAsync(It.IsAny(), It.IsAny(), + eus.FindPersonByEmailPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(endUser.ToOptional()); _endUsersService.Setup(eus => - eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) .ReturnsAsync(new EndUserWithMemberships { Id = "amembershipsuserid", @@ -264,14 +269,14 @@ public async Task WhenAuthenticateAndPersonExists_ThenIssuesToken() result.Value.RefreshToken.Value.Should().Be("arefreshtoken"); result.Value.RefreshToken.ExpiresOn.Should().Be(expiresOn); _endUsersService.Verify(eus => - eus.FindPersonByEmailAsync(_caller.Object, "auser@company.com", It.IsAny())); - _endUsersService.Verify(eus => eus.RegisterPersonAsync(It.IsAny(), It.IsAny(), + eus.FindPersonByEmailPrivateAsync(_caller.Object, "auser@company.com", It.IsAny())); + _endUsersService.Verify(eus => eus.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _ssoProvidersService.Verify(sps => sps.SaveUserInfoAsync("aprovidername", "anexistinguserid".ToId(), It.Is(ui => ui == userInfo), It.IsAny())); _endUsersService.Verify(eus => - eus.GetMembershipsAsync(_caller.Object, "anexistinguserid", It.IsAny())); + eus.GetMembershipsPrivateAsync(_caller.Object, "anexistinguserid", It.IsAny())); _authTokensService.Verify(ats => ats.IssueTokensAsync(_caller.Object, It.Is(eu => eu.Id == "amembershipsuserid" ), It.IsAny())); diff --git a/src/IdentityApplication/APIKeysApplication.cs b/src/IdentityApplication/APIKeysApplication.cs index 80e2078f..43179825 100644 --- a/src/IdentityApplication/APIKeysApplication.cs +++ b/src/IdentityApplication/APIKeysApplication.cs @@ -55,7 +55,8 @@ public async Task, Error>> FindMembershi } var retrievedUser = - await _endUsersService.GetMembershipsAsync(context, retrievedApiKey.Value.Value.UserId, cancellationToken); + await _endUsersService.GetMembershipsPrivateAsync(context, retrievedApiKey.Value.Value.UserId, + cancellationToken); if (!retrievedUser.IsSuccessful) { return Optional.None; diff --git a/src/IdentityApplication/AuthTokensApplication.cs b/src/IdentityApplication/AuthTokensApplication.cs index ef8b0595..d449b602 100644 --- a/src/IdentityApplication/AuthTokensApplication.cs +++ b/src/IdentityApplication/AuthTokensApplication.cs @@ -94,7 +94,8 @@ public async Task> RefreshTokenAsync(ICallerCo } var authTokens = retrieved.Value.Value; - var retrievedUser = await _endUsersService.GetMembershipsAsync(context, authTokens.UserId, cancellationToken); + var retrievedUser = + await _endUsersService.GetMembershipsPrivateAsync(context, authTokens.UserId, cancellationToken); if (!retrievedUser.IsSuccessful) { return retrievedUser.Error; diff --git a/src/IdentityApplication/MachineCredentialsApplication.cs b/src/IdentityApplication/MachineCredentialsApplication.cs index dfc049fa..4dfbf3dc 100644 --- a/src/IdentityApplication/MachineCredentialsApplication.cs +++ b/src/IdentityApplication/MachineCredentialsApplication.cs @@ -26,7 +26,7 @@ public async Task> RegisterMachineAsync(ICaller string? timezone, string? countryCode, DateTime? apiKeyExpiresOn, CancellationToken cancellationToken) { var registered = - await _endUsersService.RegisterMachineAsync(context, name, timezone, countryCode, cancellationToken); + await _endUsersService.RegisterMachinePrivateAsync(context, name, timezone, countryCode, cancellationToken); if (!registered.IsSuccessful) { return registered.Error; diff --git a/src/IdentityApplication/PasswordCredentialsApplication.cs b/src/IdentityApplication/PasswordCredentialsApplication.cs index 0e76fb6f..2bffadf8 100644 --- a/src/IdentityApplication/PasswordCredentialsApplication.cs +++ b/src/IdentityApplication/PasswordCredentialsApplication.cs @@ -90,7 +90,8 @@ public async Task> AuthenticateAsync(ICallerCo } var credentials = retrieved.Value.Value; - var registered = await _endUsersService.GetMembershipsAsync(context, credentials.UserId, cancellationToken); + var registered = + await _endUsersService.GetMembershipsPrivateAsync(context, credentials.UserId, cancellationToken); if (!registered.IsSuccessful) { return Error.NotAuthenticated(); @@ -192,7 +193,7 @@ public async Task> RegisterPersonAsync(ICaller bool termsAndConditionsAccepted, CancellationToken cancellationToken) { - var registered = await _endUsersService.RegisterPersonAsync(context, emailAddress, firstName, lastName, + var registered = await _endUsersService.RegisterPersonPrivateAsync(context, emailAddress, firstName, lastName, timezone, countryCode, termsAndConditionsAccepted, cancellationToken); if (!registered.IsSuccessful) { diff --git a/src/IdentityApplication/SingleSignOnApplication.cs b/src/IdentityApplication/SingleSignOnApplication.cs index c2d4f4d5..68ca7639 100644 --- a/src/IdentityApplication/SingleSignOnApplication.cs +++ b/src/IdentityApplication/SingleSignOnApplication.cs @@ -47,7 +47,7 @@ public async Task> AuthenticateAsync(ICallerCo var userInfo = authenticated.Value; var userExists = - await _endUsersService.FindPersonByEmailAsync(context, userInfo.EmailAddress, cancellationToken); + await _endUsersService.FindPersonByEmailPrivateAsync(context, userInfo.EmailAddress, cancellationToken); if (!userExists.IsSuccessful) { return userExists.Error; @@ -56,7 +56,7 @@ public async Task> AuthenticateAsync(ICallerCo string registeredUserId; if (!userExists.Value.HasValue) { - var autoRegistered = await _endUsersService.RegisterPersonAsync(context, userInfo.EmailAddress, + var autoRegistered = await _endUsersService.RegisterPersonPrivateAsync(context, userInfo.EmailAddress, userInfo.FirstName, userInfo.LastName, userInfo.Timezone.ToString(), userInfo.CountryCode.ToString(), true, cancellationToken); @@ -76,7 +76,8 @@ public async Task> AuthenticateAsync(ICallerCo registeredUserId = userExists.Value.Value.Id; } - var registered = await _endUsersService.GetMembershipsAsync(context, registeredUserId, cancellationToken); + var registered = + await _endUsersService.GetMembershipsPrivateAsync(context, registeredUserId, cancellationToken); if (!registered.IsSuccessful) { return Error.NotAuthenticated(); diff --git a/src/IdentityDomain/PasswordCredentialRoot.cs b/src/IdentityDomain/PasswordCredentialRoot.cs index 8c0b49eb..59dd91b0 100644 --- a/src/IdentityDomain/PasswordCredentialRoot.cs +++ b/src/IdentityDomain/PasswordCredentialRoot.cs @@ -80,7 +80,7 @@ private PasswordCredentialRoot(IRecorder recorder, IIdentifierFactory idFactory, public static AggregateRootFactory Rehydrate() { return (identifier, container, _) => new PasswordCredentialRoot(container.Resolve(), - container.Resolve(), container.Resolve(), + container.Resolve(), container.ResolveForPlatform(), container.Resolve(), container.Resolve(), container.Resolve(), identifier); } diff --git a/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs b/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs index 9051d4d5..b1ea0fa2 100644 --- a/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs +++ b/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs @@ -45,7 +45,7 @@ public async Task WhenRegisterPerson_ThenRegisters() result.Content.Value.Credential.User.Features.Should() .ContainSingle(feat => feat == PlatformFeatures.PaidTrial.Name); result.Content.Value.Credential.User.Profile!.Id.Should().Be(result.Content.Value.Credential.User.Id); - result.Content.Value.Credential.User.Profile!.DefaultOrganisationId.Should().BeNull(); + result.Content.Value.Credential.User.Profile!.DefaultOrganizationId.Should().NotBeNullOrEmpty(); result.Content.Value.Credential.User.Profile!.Name.FirstName.Should().Be("afirstname"); result.Content.Value.Credential.User.Profile!.Name.LastName.Should().Be("alastname"); result.Content.Value.Credential.User.Profile!.DisplayName.Should().Be("afirstname"); diff --git a/src/IdentityInfrastructure/IdentityModule.cs b/src/IdentityInfrastructure/IdentityModule.cs index 451027c5..257f31b6 100644 --- a/src/IdentityInfrastructure/IdentityModule.cs +++ b/src/IdentityInfrastructure/IdentityModule.cs @@ -3,6 +3,7 @@ using Application.Services.Shared; using Common; using Common.Configuration; +using Domain.Common.Identity; using Domain.Interfaces; using Domain.Services.Shared.DomainServices; using IdentityApplication; @@ -18,6 +19,7 @@ using Infrastructure.Common.DomainServices; using Infrastructure.Hosting.Common.Extensions; using Infrastructure.Persistence.Interfaces; +using Infrastructure.Shared.DomainServices; using Infrastructure.Web.Hosting.Common; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; @@ -49,18 +51,32 @@ public Action RegisterServices { return (_, services) => { + services.RegisterUnshared(); services.RegisterUnshared(); services.RegisterUnshared(); services.RegisterUnshared(); - services.RegisterUnshared(); + services.RegisterUnshared(c => + new JWTTokensService(c.ResolveForPlatform(), + c.ResolveForUnshared())); services.RegisterUnshared(); services.RegisterUnshared(c => new AesEncryptionService(c - .ResolveForUnshared().Platform + .ResolveForPlatform() .GetString("ApplicationServices:SSOProvidersService:SSOUserTokens:AesSecret"))); services.RegisterUnshared(); services.RegisterUnshared(); - services.RegisterUnshared(); + services.RegisterUnshared(c => new PasswordCredentialsApplication( + c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForPlatform(), + c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForUnshared())); services.RegisterUnshared(); services.RegisterUnshared(); services.RegisterUnshared(c => new PasswordCredentialsRepository( diff --git a/src/Infrastructure.Common/DomainServices/TenantSettingService.cs b/src/Infrastructure.Common/DomainServices/TenantSettingService.cs index 7be59647..05a3945e 100644 --- a/src/Infrastructure.Common/DomainServices/TenantSettingService.cs +++ b/src/Infrastructure.Common/DomainServices/TenantSettingService.cs @@ -21,4 +21,9 @@ public string Decrypt(string encryptedValue) { return _encryptionService.Decrypt(encryptedValue); } + + public string Encrypt(string plainText) + { + return _encryptionService.Encrypt(plainText); + } } \ No newline at end of file diff --git a/src/Infrastructure.Common/Recording/AWSCloudWatchMetricReporter.cs b/src/Infrastructure.Common/Recording/AWSCloudWatchMetricReporter.cs index f2aa4d50..279529a9 100644 --- a/src/Infrastructure.Common/Recording/AWSCloudWatchMetricReporter.cs +++ b/src/Infrastructure.Common/Recording/AWSCloudWatchMetricReporter.cs @@ -20,7 +20,7 @@ public class AWSCloudWatchMetricReporter : IMetricReporter public AWSCloudWatchMetricReporter(IDependencyContainer container) { - var settings = container.Resolve().Platform; + var settings = container.ResolveForPlatform(); var (credentials, regionEndpoint) = settings.GetConnection(); if (regionEndpoint.Exists()) { diff --git a/src/Infrastructure.Common/Recording/QueuedAuditReporter.cs b/src/Infrastructure.Common/Recording/QueuedAuditReporter.cs index 45dab368..3c0b28f3 100644 --- a/src/Infrastructure.Common/Recording/QueuedAuditReporter.cs +++ b/src/Infrastructure.Common/Recording/QueuedAuditReporter.cs @@ -29,7 +29,7 @@ public class QueuedAuditReporter : IAuditReporter private readonly IAuditMessageQueueRepository _repository; // ReSharper disable once UnusedParameter.Local - public QueuedAuditReporter(IDependencyContainer container, ISettings settings) + public QueuedAuditReporter(IDependencyContainer container, IConfigurationSettings settings) : this(new AuditMessageQueueRepository(NullRecorder.Instance, container.Resolve(), #if !TESTINGONLY #if HOSTEDONAZURE @@ -38,7 +38,7 @@ public QueuedAuditReporter(IDependencyContainer container, ISettings settings) AWSSQSQueueStore.Create(NullRecorder.Instance, settings) #endif #else - container.Resolve() + container.ResolveForPlatform() #endif )) { diff --git a/src/Infrastructure.Common/Recording/QueuedUsageReporter.cs b/src/Infrastructure.Common/Recording/QueuedUsageReporter.cs index 21e2cf4b..5217e1d9 100644 --- a/src/Infrastructure.Common/Recording/QueuedUsageReporter.cs +++ b/src/Infrastructure.Common/Recording/QueuedUsageReporter.cs @@ -30,7 +30,7 @@ public class QueuedUsageReporter : IUsageReporter private readonly IUsageMessageQueue _queue; // ReSharper disable once UnusedParameter.Local - public QueuedUsageReporter(IDependencyContainer container, ISettings settings) + public QueuedUsageReporter(IDependencyContainer container, IConfigurationSettings settings) : this(new UsageMessageQueue(NullRecorder.Instance, container.Resolve(), #if !TESTINGONLY #if HOSTEDONAZURE @@ -39,7 +39,7 @@ public QueuedUsageReporter(IDependencyContainer container, ISettings settings) AWSSQSQueueStore.Create(NullRecorder.Instance, settings) #endif #else - container.Resolve() + container.ResolveForPlatform() #endif )) { diff --git a/src/Infrastructure.Common/SimpleTenancyContext.cs b/src/Infrastructure.Common/SimpleTenancyContext.cs index 3f3f4f31..c2d620bb 100644 --- a/src/Infrastructure.Common/SimpleTenancyContext.cs +++ b/src/Infrastructure.Common/SimpleTenancyContext.cs @@ -1,3 +1,4 @@ +using Application.Interfaces.Services; using Infrastructure.Interfaces; namespace Infrastructure.Common; @@ -9,9 +10,9 @@ public class SimpleTenancyContext : ITenancyContext { public string? Current { get; private set; } - public IReadOnlyDictionary Settings { get; private set; } = new Dictionary(); + public TenantSettings Settings { get; private set; } = new(); - public void Set(string id, Dictionary settings) + public void Set(string id, TenantSettings settings) { Current = id; Settings = settings; diff --git a/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/AspNetConfigurationSettingsSpec.cs b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/AspNetConfigurationSettingsSpec.cs deleted file mode 100644 index 8ced7c77..00000000 --- a/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/AspNetConfigurationSettingsSpec.cs +++ /dev/null @@ -1,301 +0,0 @@ -using Common.Configuration; -using Common.Extensions; -using FluentAssertions; -using Infrastructure.Interfaces; -using JetBrains.Annotations; -using Microsoft.Extensions.Configuration; -using Moq; -using Xunit; - -namespace Infrastructure.Hosting.Common.UnitTests.ApplicationServices; - -[UsedImplicitly] -public class AspNetConfigurationSettingsSpec -{ - private static IConfigurationSettings SetupPlatformConfiguration(Dictionary values) - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(values!) - .Build(); - - return new AspNetConfigurationSettings(configuration); - } - - private static IConfigurationSettings SetupTenancyConfiguration(Mock context, - Dictionary values) - { - var configuration = new ConfigurationBuilder() - .Build(); - - context.Setup(ctx => ctx.Settings).Returns(values!); - - return new AspNetConfigurationSettings(configuration, context.Object); - } - - [Trait("Category", "Unit")] - public class GivenOnlyPlatformConfiguration - { - [Fact] - public void WhenGetStringFromPlatformAndNotExists_ThenThrows() - { - var settings = SetupPlatformConfiguration(new Dictionary()); - - settings.Platform - .Invoking(x => x.GetString("akey")) - .Should().Throw() - .WithMessage(Resources.AspNetConfigurationSettings_KeyNotFound.Format("akey")); - } - - [Fact] - public void WhenGetStringFromPlatformAndExists_ThenReturns() - { - var settings = SetupPlatformConfiguration(new Dictionary - { - { "akey", "avalue" } - }); - - var result = settings.Platform.GetString("akey"); - - result.Should().Be("avalue"); - } - - [Fact] - public void WhenGetStringFromPlatformAndEmpty_ThenReturnsEmpty() - { - var settings = SetupPlatformConfiguration(new Dictionary - { - { "akey", string.Empty } - }); - - var result = settings.Platform.GetString("akey"); - - result.Should().Be(string.Empty); - } - - [Fact] - public void WhenGetNestedStringFromPlatformAndExists_ThenReturns() - { - var settings = SetupPlatformConfiguration(new Dictionary - { - { "anamespace.akey", "avalue" } - }); - - var result = settings.Platform.GetString("anamespace.akey"); - - result.Should().Be("avalue"); - } - - [Fact] - public void WhenGetBoolFromPlatformAndNotExists_ThenThrows() - { - var settings = SetupPlatformConfiguration(new Dictionary()); - - settings.Platform - .Invoking(x => x.GetBool("akey")) - .Should().Throw() - .WithMessage(Resources.AspNetConfigurationSettings_KeyNotFound.Format("akey")); - } - - [Fact] - public void WhenGetBoolFromPlatformAndExists_ThenReturns() - { - var settings = SetupPlatformConfiguration(new Dictionary - { - { "akey", "true" } - }); - - var result = settings.Platform.GetBool("akey"); - - result.Should().BeTrue(); - } - - [Fact] - public void WhenGetBoolFromPlatformAndNotBoolean_ThenThrows() - { - var settings = SetupPlatformConfiguration(new Dictionary - { - { "akey", "notaboolvalue" } - }); - - settings.Platform - .Invoking(x => x.GetBool("akey")) - .Should().Throw() - .WithMessage(Resources.AspNetConfigurationSettings_ValueNotBoolean.Format("akey")); - } - - [Fact] - public void WhenGetNumberFromPlatformAndNotExists_ThenThrows() - { - var settings = SetupPlatformConfiguration(new Dictionary()); - - settings.Platform - .Invoking(x => x.GetNumber("akey")) - .Should().Throw() - .WithMessage(Resources.AspNetConfigurationSettings_KeyNotFound.Format("akey")); - } - - [Fact] - public void WhenGetNumberFromPlatformAndExists_ThenReturns() - { - var settings = SetupPlatformConfiguration(new Dictionary - { - { "akey", "999" } - }); - - var result = settings.Platform.GetNumber("akey"); - - result.Should().Be(999); - } - - [Fact] - public void WhenGetNumberFromPlatformAndNotANumber_ThenThrows() - { - var settings = SetupPlatformConfiguration(new Dictionary - { - { "akey", "notanumbervalue" } - }); - - settings.Platform - .Invoking(x => x.GetNumber("akey")) - .Should().Throw() - .WithMessage(Resources.AspNetConfigurationSettings_ValueNotNumber.Format("akey")); - } - } - - [Trait("Category", "Unit")] - public class GivenTenancyConfiguration - { - private readonly Mock _tenantContext; - - public GivenTenancyConfiguration() - { - _tenantContext = new Mock(); - } - - [Fact] - public void WhenGetStringFromTenancyAndNotExists_ThenThrows() - { - var settings = SetupTenancyConfiguration(_tenantContext, new Dictionary()); - - settings.Tenancy - .Invoking(x => x.GetString("akey")) - .Should().Throw() - .WithMessage(Resources.AspNetConfigurationSettings_KeyNotFound.Format("akey")); - } - - [Fact] - public void WhenGetStringFromTenancyAndExists_ThenReturns() - { - var settings = SetupTenancyConfiguration(_tenantContext, new Dictionary - { - { "akey", "avalue" } - }); - - var result = settings.Tenancy.GetString("akey"); - - result.Should().Be("avalue"); - } - - [Fact] - public void WhenGetStringFromTenancyAndEmpty_ThenReturnsEmpty() - { - var settings = SetupTenancyConfiguration(_tenantContext, new Dictionary - { - { "akey", string.Empty } - }); - - var result = settings.Tenancy.GetString("akey"); - - result.Should().Be(string.Empty); - } - - [Fact] - public void WhenGetNestedStringFromPlatformAndExists_ThenReturns() - { - var settings = SetupTenancyConfiguration(_tenantContext, new Dictionary - { - { "anamespace.akey", "avalue" } - }); - - var result = settings.Tenancy.GetString("anamespace.akey"); - - result.Should().Be("avalue"); - } - - [Fact] - public void WhenGetBoolFromTenancyAndNotExists_ThenThrows() - { - var settings = SetupTenancyConfiguration(_tenantContext, new Dictionary()); - - settings.Tenancy - .Invoking(x => x.GetBool("akey")) - .Should().Throw() - .WithMessage(Resources.AspNetConfigurationSettings_KeyNotFound.Format("akey")); - } - - [Fact] - public void WhenGetBoolFromTenancyAndExists_ThenReturns() - { - var settings = SetupTenancyConfiguration(_tenantContext, new Dictionary - { - { "akey", true } - }); - - var result = settings.Tenancy.GetBool("akey"); - - result.Should().BeTrue(); - } - - [Fact] - public void WhenGetBoolFromTenancyAndNotBoolean_ThenThrows() - { - var settings = SetupTenancyConfiguration(_tenantContext, new Dictionary - { - { "akey", "notaboolvalue" } - }); - - settings.Tenancy - .Invoking(x => x.GetBool("akey")) - .Should().Throw() - .WithMessage(Resources.AspNetConfigurationSettings_ValueNotBoolean.Format("akey")); - } - - [Fact] - public void WhenGetNumberFromTenancyAndNotExists_ThenThrows() - { - var settings = SetupTenancyConfiguration(_tenantContext, new Dictionary()); - - settings.Tenancy - .Invoking(x => x.GetNumber("akey")) - .Should().Throw() - .WithMessage(Resources.AspNetConfigurationSettings_KeyNotFound.Format("akey")); - } - - [Fact] - public void WhenGetNumberFromTenancyAndExists_ThenReturns() - { - var settings = SetupTenancyConfiguration(_tenantContext, new Dictionary - { - { "akey", 999 } - }); - - var result = settings.Tenancy.GetNumber("akey"); - - result.Should().Be(999); - } - - [Fact] - public void WhenGetNumberFromTenancyAndNotANumber_ThenThrows() - { - var settings = SetupTenancyConfiguration(_tenantContext, new Dictionary - { - { "akey", "notanumbervalue" } - }); - - settings.Tenancy - .Invoking(x => x.GetNumber("akey")) - .Should().Throw() - .WithMessage(Resources.AspNetConfigurationSettings_ValueNotNumber.Format("akey")); - } - } -} \ No newline at end of file diff --git a/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/AspNetDynamicConfigurationSettingsSpec.cs b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/AspNetDynamicConfigurationSettingsSpec.cs new file mode 100644 index 00000000..a65b8ed3 --- /dev/null +++ b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/AspNetDynamicConfigurationSettingsSpec.cs @@ -0,0 +1,478 @@ +using Application.Interfaces.Services; +using Common.Configuration; +using Common.Extensions; +using FluentAssertions; +using Infrastructure.Interfaces; +using JetBrains.Annotations; +using Microsoft.Extensions.Configuration; +using Moq; +using Xunit; + +namespace Infrastructure.Hosting.Common.UnitTests.ApplicationServices; + +[UsedImplicitly] +public class AspNetDynamicConfigurationSettingsSpec +{ + [Trait("Category", "Unit")] + public class GivenOnlyPlatformConfiguration + { + [Fact] + public void WhenGetStringAndNotExists_ThenThrows() + { + var settings = SetupPlatformConfiguration(new Dictionary()); + + settings + .Invoking(x => x.GetString("akey")) + .Should().Throw() + .WithMessage(Resources.AspNetDynamicConfigurationSettings_PlatformSettings_KeyNotFound.Format("akey")); + } + + [Fact] + public void WhenGetStringAndExists_ThenReturns() + { + var settings = SetupPlatformConfiguration(new Dictionary + { + { "akey", "avalue" } + }); + + var result = settings.GetString("akey"); + + result.Should().Be("avalue"); + } + + [Fact] + public void WhenGetStringAndEmpty_ThenReturnsEmpty() + { + var settings = SetupPlatformConfiguration(new Dictionary + { + { "akey", string.Empty } + }); + + var result = settings.GetString("akey"); + + result.Should().Be(string.Empty); + } + + [Fact] + public void WhenGetNestedStringAndExists_ThenReturns() + { + var settings = SetupPlatformConfiguration(new Dictionary + { + { "anamespace.akey", "avalue" } + }); + + var result = settings.GetString("anamespace.akey"); + + result.Should().Be("avalue"); + } + + [Fact] + public void WhenGetBoolAndNotExists_ThenThrows() + { + var settings = SetupPlatformConfiguration(new Dictionary()); + + settings + .Invoking(x => x.GetBool("akey")) + .Should().Throw() + .WithMessage(Resources.AspNetDynamicConfigurationSettings_PlatformSettings_KeyNotFound.Format("akey")); + } + + [Fact] + public void WhenGetBoolAndExists_ThenReturns() + { + var settings = SetupPlatformConfiguration(new Dictionary + { + { "akey", "true" } + }); + + var result = settings.GetBool("akey"); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenGetBoolAndNotBoolean_ThenThrows() + { + var settings = SetupPlatformConfiguration(new Dictionary + { + { "akey", "notaboolvalue" } + }); + + settings + .Invoking(x => x.GetBool("akey")) + .Should().Throw() + .WithMessage(Resources.AspNetDynamicConfigurationSettings_ValueNotBoolean.Format("akey")); + } + + [Fact] + public void WhenGetNumberAndNotExists_ThenThrows() + { + var settings = SetupPlatformConfiguration(new Dictionary()); + + settings + .Invoking(x => x.GetNumber("akey")) + .Should().Throw() + .WithMessage(Resources.AspNetDynamicConfigurationSettings_PlatformSettings_KeyNotFound.Format("akey")); + } + + [Fact] + public void WhenGetNumberAndExists_ThenReturns() + { + var settings = SetupPlatformConfiguration(new Dictionary + { + { "akey", "999" } + }); + + var result = settings.GetNumber("akey"); + + result.Should().Be(999); + } + + [Fact] + public void WhenGetNumberAndNotANumber_ThenThrows() + { + var settings = SetupPlatformConfiguration(new Dictionary + { + { "akey", "notanumbervalue" } + }); + + settings + .Invoking(x => x.GetNumber("akey")) + .Should().Throw() + .WithMessage(Resources.AspNetDynamicConfigurationSettings_ValueNotNumber.Format("akey")); + } + + private static IConfigurationSettings SetupPlatformConfiguration(Dictionary values) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(values!) + .Build(); + + return new AspNetDynamicConfigurationSettings(configuration); + } + } + + [Trait("Category", "Unit")] + public class GivenTenancyAndNoPlatformConfiguration + { + private readonly Mock _tenantContext; + + public GivenTenancyAndNoPlatformConfiguration() + { + _tenantContext = new Mock(); + } + + [Fact] + public void WhenGetStringAndNotExists_ThenThrows() + { + var settings = SetupTenancyConfiguration(_tenantContext, new TenantSettings()); + + settings + .Invoking(x => x.GetString("akey")) + .Should().Throw() + .WithMessage(Resources.AspNetDynamicConfigurationSettings_EitherSettings_KeyNotFound.Format("akey")); + } + + [Fact] + public void WhenGetStringAndExists_ThenReturns() + { + var settings = SetupTenancyConfiguration(_tenantContext, new TenantSettings(new Dictionary + { + { "akey", "avalue" } + })); + + var result = settings.GetString("akey"); + + result.Should().Be("avalue"); + } + + [Fact] + public void WhenGetStringAndEmpty_ThenReturnsEmpty() + { + var settings = SetupTenancyConfiguration(_tenantContext, new TenantSettings(new Dictionary + { + { "akey", string.Empty } + })); + + var result = settings.GetString("akey"); + + result.Should().Be(string.Empty); + } + + [Fact] + public void WhenGetNestedStringAndExists_ThenReturns() + { + var settings = SetupTenancyConfiguration(_tenantContext, new TenantSettings(new Dictionary + { + { "anamespace.akey", "avalue" } + })); + + var result = settings.GetString("anamespace.akey"); + + result.Should().Be("avalue"); + } + + [Fact] + public void WhenGetBoolAndNotExists_ThenThrows() + { + var settings = SetupTenancyConfiguration(_tenantContext, new TenantSettings()); + + settings + .Invoking(x => x.GetBool("akey")) + .Should().Throw() + .WithMessage(Resources.AspNetDynamicConfigurationSettings_EitherSettings_KeyNotFound.Format("akey")); + } + + [Fact] + public void WhenGetBoolAndExists_ThenReturns() + { + var settings = SetupTenancyConfiguration(_tenantContext, new TenantSettings(new Dictionary + { + { "akey", true } + })); + + var result = settings.GetBool("akey"); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenGetBoolAndNotBoolean_ThenThrows() + { + var settings = SetupTenancyConfiguration(_tenantContext, new TenantSettings(new Dictionary + { + { "akey", "notaboolvalue" } + })); + + settings + .Invoking(x => x.GetBool("akey")) + .Should().Throw() + .WithMessage(Resources.AspNetDynamicConfigurationSettings_ValueNotBoolean.Format("akey")); + } + + [Fact] + public void WhenGetNumberAndNotExists_ThenThrows() + { + var settings = SetupTenancyConfiguration(_tenantContext, new TenantSettings()); + + settings + .Invoking(x => x.GetNumber("akey")) + .Should().Throw() + .WithMessage(Resources.AspNetDynamicConfigurationSettings_EitherSettings_KeyNotFound.Format("akey")); + } + + [Fact] + public void WhenGetNumberAndExists_ThenReturns() + { + var settings = SetupTenancyConfiguration(_tenantContext, new TenantSettings(new Dictionary + { + { "akey", 999 } + })); + + var result = settings.GetNumber("akey"); + + result.Should().Be(999); + } + + [Fact] + public void WhenGetNumberAndNotANumber_ThenThrows() + { + var settings = SetupTenancyConfiguration(_tenantContext, new TenantSettings(new Dictionary + { + { "akey", "notanumbervalue" } + })); + + settings + .Invoking(x => x.GetNumber("akey")) + .Should().Throw() + .WithMessage(Resources.AspNetDynamicConfigurationSettings_ValueNotNumber.Format("akey")); + } + + private static IConfigurationSettings SetupTenancyConfiguration(Mock context, + TenantSettings values) + { + var configuration = new ConfigurationBuilder() + .Build(); + + context.Setup(ctx => ctx.Settings).Returns(values); + + return new AspNetDynamicConfigurationSettings(configuration, context.Object); + } + } + + [Trait("Category", "Unit")] + public class GivenTenancyAndPlatformConfiguration + { + private readonly Mock _tenantContext; + + public GivenTenancyAndPlatformConfiguration() + { + _tenantContext = new Mock(); + } + + [Fact] + public void WhenGetStringAndNotExists_ThenReturnsPlatformValue() + { + var settings = SetupPlatformAndTenancyConfiguration(_tenantContext, new Dictionary + { + { "akey", "aplatformvalue" } + }, new TenantSettings()); + + var result = settings.GetString("akey"); + + result.Should().Be("aplatformvalue"); + } + + [Fact] + public void WhenGetStringAndExistsInTenant_ThenReturnsTenantSetting() + { + var settings = SetupPlatformAndTenancyConfiguration(_tenantContext, new Dictionary + { + { "akey", "aplatformvalue" } + }, new TenantSettings(new Dictionary + { + { "akey", "atenantvalue" } + })); + + var result = settings.GetString("akey"); + + result.Should().Be("atenantvalue"); + } + + [Fact] + public void WhenGetStringAndEmpty_ThenReturnsEmpty() + { + var settings = SetupPlatformAndTenancyConfiguration(_tenantContext, new Dictionary + { + { "akey", "aplatformvalue" } + }, new TenantSettings(new Dictionary + { + { "akey", string.Empty } + })); + + var result = settings.GetString("akey"); + + result.Should().Be(string.Empty); + } + + [Fact] + public void WhenGetNestedStringAndExists_ThenReturns() + { + var settings = SetupPlatformAndTenancyConfiguration(_tenantContext, new Dictionary + { + { "anamespace.akey", "aplatformvalue" } + }, new TenantSettings(new Dictionary + { + { "anamespace.akey", "atenantvalue" } + })); + + var result = settings.GetString("anamespace.akey"); + + result.Should().Be("atenantvalue"); + } + + [Fact] + public void WhenGetBoolAndNotExists_ThenReturnsPlatformValue() + { + var settings = SetupPlatformAndTenancyConfiguration(_tenantContext, new Dictionary + { + { "akey", true } + }, new TenantSettings()); + + var result = settings.GetBool("akey"); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenGetBoolAndExistsInTenant_ThenReturnsTenantSetting() + { + var settings = SetupPlatformAndTenancyConfiguration(_tenantContext, new Dictionary + { + { "akey", false } + }, new TenantSettings(new Dictionary + { + { "akey", true } + })); + + var result = settings.GetBool("akey"); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenGetBoolAndNotBoolean_ThenThrows() + { + var settings = SetupPlatformAndTenancyConfiguration(_tenantContext, new Dictionary + { + { "akey", true } + }, new TenantSettings(new Dictionary + { + { "akey", "notaboolvalue" } + })); + + settings + .Invoking(x => x.GetBool("akey")) + .Should().Throw() + .WithMessage(Resources.AspNetDynamicConfigurationSettings_ValueNotBoolean.Format("akey")); + } + + [Fact] + public void WhenGetNumberAndNotExists_ThenReturnsPlatformValue() + { + var settings = SetupPlatformAndTenancyConfiguration(_tenantContext, new Dictionary + { + { "akey", 99 } + }, new TenantSettings()); + + var result = settings.GetNumber("akey"); + + result.Should().Be(99); + } + + [Fact] + public void WhenGetNumberAndExistsInTenant_ThenReturnsTenantSetting() + { + var settings = SetupPlatformAndTenancyConfiguration(_tenantContext, new Dictionary + { + { "akey", 99 } + }, new TenantSettings(new Dictionary + { + { "akey", 66 } + })); + + var result = settings.GetNumber("akey"); + + result.Should().Be(66); + } + + [Fact] + public void WhenGetNumberAndNotANumber_ThenThrows() + { + var settings = SetupPlatformAndTenancyConfiguration(_tenantContext, new Dictionary + { + { "akey", 99 } + }, new TenantSettings(new Dictionary + { + { "akey", "notanumbervalue" } + })); + + settings + .Invoking(x => x.GetNumber("akey")) + .Should().Throw() + .WithMessage(Resources.AspNetDynamicConfigurationSettings_ValueNotNumber.Format("akey")); + } + + private static IConfigurationSettings SetupPlatformAndTenancyConfiguration(Mock context, + Dictionary values, TenantSettings tenantSettings) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(values.ToDictionary(pair => pair.Key, pair => pair.Value.ToString())) + .Build(); + + context.Setup(ctx => ctx.Settings).Returns(tenantSettings); + + return new AspNetDynamicConfigurationSettings(configuration, context.Object); + } + } +} \ No newline at end of file diff --git a/src/Infrastructure.Hosting.Common/AspNetConfigurationSettings.cs b/src/Infrastructure.Hosting.Common/AspNetConfigurationSettings.cs deleted file mode 100644 index 07b249e3..00000000 --- a/src/Infrastructure.Hosting.Common/AspNetConfigurationSettings.cs +++ /dev/null @@ -1,188 +0,0 @@ -using Common; -using Common.Configuration; -using Common.Extensions; -using Infrastructure.Interfaces; -using Microsoft.Extensions.Configuration; - -namespace Infrastructure.Hosting.Common; - -/// -/// Provides settings read from .NET configuration -/// -public class AspNetConfigurationSettings : IConfigurationSettings -{ - private readonly Optional _tenantedSettings; - - public AspNetConfigurationSettings(IConfiguration configuration, ITenancyContext tenancy) - { - Platform = new AppSettingsWrapper(configuration); - _tenantedSettings = new TenantedSettings(tenancy); - } - - public AspNetConfigurationSettings(IConfiguration globalSettings) - { - Platform = new AppSettingsWrapper(globalSettings); - _tenantedSettings = Optional.None; - } - - internal AspNetConfigurationSettings(IConfiguration globalSettings, IConfiguration tenancySettings) - { - Platform = new AppSettingsWrapper(globalSettings); - _tenantedSettings = new AppSettingsWrapper(tenancySettings); - } - - public ISettings Platform { get; } - - public ISettings Tenancy - { - get - { - if (!_tenantedSettings.HasValue) - { - throw new NotImplementedException(); - } - - return _tenantedSettings.Value; - } - } - - private sealed class AppSettingsWrapper : ISettings - { - private readonly IConfiguration _configuration; - - public AppSettingsWrapper(IConfiguration configuration) - { - _configuration = configuration; - } - - public bool GetBool(string key, bool? defaultValue = null) - { - var value = _configuration.GetValue(key); - if (value.HasNoValue()) - { - if (defaultValue.HasValue) - { - return defaultValue.Value; - } - - throw new InvalidOperationException(Resources.AspNetConfigurationSettings_KeyNotFound.Format(key)); - } - - if (!bool.TryParse(value, out var boolean)) - { - throw new InvalidOperationException(Resources.AspNetConfigurationSettings_ValueNotBoolean.Format(key)); - } - - return boolean; - } - - public double GetNumber(string key, double? defaultValue = null) - { - var value = _configuration.GetValue(key); - if (value.HasNoValue()) - { - if (defaultValue.HasValue) - { - return defaultValue.Value; - } - - throw new InvalidOperationException(Resources.AspNetConfigurationSettings_KeyNotFound.Format(key)); - } - - if (!double.TryParse(value, out var number)) - { - throw new InvalidOperationException(Resources.AspNetConfigurationSettings_ValueNotNumber.Format(key)); - } - - return number; - } - - public string GetString(string key, string? defaultValue = null) - { - var value = _configuration.GetValue(key); - if (value.NotExists()) - { - if (defaultValue.Exists()) - { - return defaultValue; - } - - throw new InvalidOperationException(Resources.AspNetConfigurationSettings_KeyNotFound.Format(key)); - } - - return value; - } - - public bool IsConfigured => true; - } - - private sealed class TenantedSettings : ISettings - { - private readonly ITenancyContext _tenancy; - - public TenantedSettings(ITenancyContext tenancy) - { - _tenancy = tenancy; - } - - public bool GetBool(string key, bool? defaultValue = null) - { - var settings = _tenancy.Settings; - if (!settings.TryGetValue(key, out var value)) - { - if (defaultValue.HasValue) - { - return defaultValue.Value; - } - - throw new InvalidOperationException(Resources.AspNetConfigurationSettings_KeyNotFound.Format(key)); - } - - if (!bool.TryParse(value.ToString(), out var boolean)) - { - throw new InvalidOperationException(Resources.AspNetConfigurationSettings_ValueNotBoolean.Format(key)); - } - - return boolean; - } - - public double GetNumber(string key, double? defaultValue = null) - { - var settings = _tenancy.Settings; - if (!settings.TryGetValue(key, out var value)) - { - if (defaultValue.HasValue) - { - return defaultValue.Value; - } - - throw new InvalidOperationException(Resources.AspNetConfigurationSettings_KeyNotFound.Format(key)); - } - - if (!double.TryParse(value.ToString(), out var number)) - { - throw new InvalidOperationException(Resources.AspNetConfigurationSettings_ValueNotNumber.Format(key)); - } - - return number; - } - - public string GetString(string key, string? defaultValue = null) - { - var settings = _tenancy.Settings; - if (!settings.TryGetValue(key, out var value)) - { - if (defaultValue.Exists()) - { - return defaultValue; - } - - throw new InvalidOperationException(Resources.AspNetConfigurationSettings_KeyNotFound.Format(key)); - } - - return value.ToString() ?? string.Empty; - } - - public bool IsConfigured => _tenancy.Current.HasValue(); - } -} \ No newline at end of file diff --git a/src/Infrastructure.Hosting.Common/AspNetDynamicConfigurationSettings.cs b/src/Infrastructure.Hosting.Common/AspNetDynamicConfigurationSettings.cs new file mode 100644 index 00000000..79345038 --- /dev/null +++ b/src/Infrastructure.Hosting.Common/AspNetDynamicConfigurationSettings.cs @@ -0,0 +1,381 @@ +using Common; +using Common.Configuration; +using Common.Extensions; +using Infrastructure.Interfaces; +using Microsoft.Extensions.Configuration; + +namespace Infrastructure.Hosting.Common; + +/// +/// Provides settings dynamically from the current Tenancy first (if available), +/// then from Platform settings read from .NET configuration. +/// A setting will only be used if all these conditions are satisfied: +/// 1. The ctor that includes is used, +/// and the instance is registered in the DI container as "scoped". +/// 2. The has a value for the , which will only +/// be true is the current HTTP request is for a tenant. +/// 3. The setting is defined in the +/// +public class AspNetDynamicConfigurationSettings : IConfigurationSettings +{ + private readonly ISettingsSafely _platform; + private readonly Optional _tenantSettings; + + public AspNetDynamicConfigurationSettings(IConfiguration configuration, ITenancyContext tenancy) + { + _platform = new PlatformSettings(configuration); + _tenantSettings = new TenantSettings(tenancy); + } + + public AspNetDynamicConfigurationSettings(IConfiguration configuration) + { + _platform = new PlatformSettings(configuration); + _tenantSettings = Optional.None; + } + + private bool IsTenanted => _tenantSettings.HasValue; + + public ISettings Platform => _platform; + + public ISettings Tenancy + { + get + { + if (!IsTenanted) + { + throw new InvalidOperationException(Resources.AspNetDynamicConfigurationSettings_NoTenantSettings); + } + + return _tenantSettings.Value; + } + } + + bool ISettings.IsConfigured => true; + + public bool GetBool(string key, bool? defaultValue = null) + { + if (IsTenanted) + { + var value = _tenantSettings.Value.GetBoolSafely(key, defaultValue); + if (value.HasValue) + { + return value.Value; + } + + value = _platform.GetBoolSafely(key, defaultValue); + if (value.HasValue) + { + return value.Value; + } + + throw new InvalidOperationException(Resources.AspNetDynamicConfigurationSettings_EitherSettings_KeyNotFound + .Format(key)); + } + + return Platform.GetBool(key, defaultValue); + } + + public double GetNumber(string key, double? defaultValue = null) + { + if (IsTenanted) + { + var value = _tenantSettings.Value.GetNumberSafely(key, defaultValue); + if (value.HasValue) + { + return value.Value; + } + + value = _platform.GetNumberSafely(key, defaultValue); + if (value.HasValue) + { + return value.Value; + } + + throw new InvalidOperationException(Resources.AspNetDynamicConfigurationSettings_EitherSettings_KeyNotFound + .Format(key)); + } + + return Platform.GetNumber(key, defaultValue); + } + + public string GetString(string key, string? defaultValue = null) + { + if (IsTenanted) + { + var value = _tenantSettings.Value.GetStringSafely(key, defaultValue); + if (value.HasValue) + { + return value; + } + + value = _platform.GetStringSafely(key, defaultValue); + if (value.HasValue) + { + return value.Value; + } + + throw new InvalidOperationException(Resources.AspNetDynamicConfigurationSettings_EitherSettings_KeyNotFound + .Format(key)); + } + + return Platform.GetString(key, defaultValue); + } + + private interface ISettingsSafely : ISettings + { + Optional GetBoolSafely(string key, bool? defaultValue = null); + + Optional GetNumberSafely(string key, double? defaultValue = null); + + Optional GetStringSafely(string key, string? defaultValue = null); + } + + private sealed class PlatformSettings : ISettingsSafely + { + private readonly IConfiguration _configuration; + + public PlatformSettings(IConfiguration configuration) + { + _configuration = configuration; + } + + public bool GetBool(string key, bool? defaultValue = null) + { + var value = GetBoolSafely(key, defaultValue); + if (value.HasValue) + { + return value.Value; + } + + throw new InvalidOperationException(Resources + .AspNetDynamicConfigurationSettings_PlatformSettings_KeyNotFound + .Format(key)); + } + + public double GetNumber(string key, double? defaultValue = null) + { + var value = GetNumberSafely(key, defaultValue); + if (value.HasValue) + { + return value.Value; + } + + throw new InvalidOperationException(Resources + .AspNetDynamicConfigurationSettings_PlatformSettings_KeyNotFound + .Format(key)); + } + + public string GetString(string key, string? defaultValue = null) + { + var value = GetStringSafely(key, defaultValue); + if (value.HasValue) + { + return value.Value; + } + + throw new InvalidOperationException(Resources + .AspNetDynamicConfigurationSettings_PlatformSettings_KeyNotFound + .Format(key)); + } + + public bool IsConfigured => true; + + public Optional GetBoolSafely(string key, bool? defaultValue = null) + { + var value = _configuration.GetValue(key); + if (value.HasNoValue()) + { + if (defaultValue.HasValue) + { + return defaultValue.Value; + } + + return Optional.None; + } + + if (!bool.TryParse(value, out var boolean)) + { + throw new InvalidOperationException( + Resources.AspNetDynamicConfigurationSettings_ValueNotBoolean.Format(key)); + } + + return boolean; + } + + public Optional GetNumberSafely(string key, double? defaultValue = null) + { + var value = _configuration.GetValue(key); + if (value.HasNoValue()) + { + if (defaultValue.HasValue) + { + return defaultValue.Value; + } + + return Optional.None; + } + + if (!double.TryParse(value, out var number)) + { + throw new InvalidOperationException( + Resources.AspNetDynamicConfigurationSettings_ValueNotNumber.Format(key)); + } + + return number; + } + + public Optional GetStringSafely(string key, string? defaultValue = null) + { + var value = _configuration.GetValue(key); + if (value.NotExists()) + { + if (defaultValue.Exists()) + { + return defaultValue; + } + + return Optional.None; + } + + return value; + } + } + + private sealed class TenantSettings : ISettingsSafely + { + private readonly ITenancyContext _tenancy; + + public TenantSettings(ITenancyContext tenancy) + { + _tenancy = tenancy; + } + + public bool GetBool(string key, bool? defaultValue = null) + { + var value = GetBoolSafely(key, defaultValue); + if (value.HasValue) + { + return value.Value; + } + + throw new InvalidOperationException(Resources.AspNetDynamicConfigurationSettings_TenantSettings_KeyNotFound + .Format(key)); + } + + public double GetNumber(string key, double? defaultValue = null) + { + var value = GetNumberSafely(key, defaultValue); + if (value.HasValue) + { + return value.Value; + } + + throw new InvalidOperationException(Resources.AspNetDynamicConfigurationSettings_TenantSettings_KeyNotFound + .Format(key)); + } + + public string GetString(string key, string? defaultValue = null) + { + var value = GetStringSafely(key, defaultValue); + if (value.HasValue) + { + return value.Value; + } + + throw new InvalidOperationException(Resources.AspNetDynamicConfigurationSettings_TenantSettings_KeyNotFound + .Format(key)); + } + + public bool IsConfigured => _tenancy.Current.HasValue(); + + public Optional GetBoolSafely(string key, bool? defaultValue = null) + { + var settings = _tenancy.Settings; + if (!settings.TryGetValue(key, out var value)) + { + if (defaultValue.HasValue) + { + return defaultValue.Value; + } + + return Optional.None; + } + + if (value.Value.NotExists()) + { + if (defaultValue.HasValue) + { + return defaultValue.Value; + } + + return Optional.None; + } + + if (!bool.TryParse(value.Value.ToString(), out var boolean)) + { + throw new InvalidOperationException( + Resources.AspNetDynamicConfigurationSettings_ValueNotBoolean.Format(key)); + } + + return boolean; + } + + public Optional GetNumberSafely(string key, double? defaultValue = null) + { + var settings = _tenancy.Settings; + if (!settings.TryGetValue(key, out var value)) + { + if (defaultValue.HasValue) + { + return defaultValue.Value; + } + + return Optional.None; + } + + if (value.Value.NotExists()) + { + if (defaultValue.HasValue) + { + return defaultValue.Value; + } + + return Optional.None; + } + + if (!double.TryParse(value.Value.ToString(), out var number)) + { + throw new InvalidOperationException( + Resources.AspNetDynamicConfigurationSettings_ValueNotNumber.Format(key)); + } + + return number; + } + + public Optional GetStringSafely(string key, string? defaultValue = null) + { + var settings = _tenancy.Settings; + if (!settings.TryGetValue(key, out var value)) + { + if (defaultValue.Exists()) + { + return defaultValue; + } + + return Optional.None; + } + + if (value.Value.NotExists()) + { + if (defaultValue.Exists()) + { + return defaultValue; + } + + return Optional.None; + } + + return value.Value.ToString() ?? string.Empty; + } + } +} \ No newline at end of file diff --git a/src/Infrastructure.Common/DotNetDependencyContainer.cs b/src/Infrastructure.Hosting.Common/DotNetDependencyContainer.cs similarity index 69% rename from src/Infrastructure.Common/DotNetDependencyContainer.cs rename to src/Infrastructure.Hosting.Common/DotNetDependencyContainer.cs index d92516f2..db6f692e 100644 --- a/src/Infrastructure.Common/DotNetDependencyContainer.cs +++ b/src/Infrastructure.Hosting.Common/DotNetDependencyContainer.cs @@ -1,7 +1,8 @@ using Domain.Interfaces.Services; +using Infrastructure.Hosting.Common.Extensions; using Microsoft.Extensions.DependencyInjection; -namespace Infrastructure.Common; +namespace Infrastructure.Hosting.Common; /// /// Provides a simple dependency injection container that uses the .NET @@ -23,6 +24,12 @@ public DotNetDependencyContainer(IServiceProvider serviceProvider) public TService Resolve() where TService : notnull { - return _serviceProvider.GetRequiredService(); + return _serviceProvider.ResolveForUnshared(); + } + + public TService ResolveForPlatform() + where TService : notnull + { + return _serviceProvider.ResolveForPlatform(); } } \ No newline at end of file diff --git a/src/Infrastructure.Hosting.Common/Extensions/ServiceCollectionExtensions.cs b/src/Infrastructure.Hosting.Common/Extensions/ServiceCollectionExtensions.cs index 953b6a9e..d8d79c09 100644 --- a/src/Infrastructure.Hosting.Common/Extensions/ServiceCollectionExtensions.cs +++ b/src/Infrastructure.Hosting.Common/Extensions/ServiceCollectionExtensions.cs @@ -126,7 +126,7 @@ public static IServiceCollection RegisterPlatform /// Registers an instance of the as per request (scoped), - /// only for services that must be initialized for tenanted services + /// only for services that must be initialized for each HTTP request /// public static IServiceCollection RegisterTenanted(this IServiceCollection services, Func implementationFactory) @@ -138,7 +138,7 @@ public static IServiceCollection RegisterTenanted(this IServiceCollect /// /// Registers an instance of the as per request (scoped), - /// only for services that must be initialized for tenanted services + /// only for services that must be initialized for each HTTP request /// public static IServiceCollection RegisterTenanted(this IServiceCollect /// /// Registers an instance of the as per request (scoped), - /// only for services that must be initialized for tenanted services + /// only for services that must be initialized for each HTTP request /// public static IServiceCollection RegisterTenanted(this IServiceCollection services, Type serviceType, Func implementationFactory) @@ -165,7 +165,7 @@ public static IServiceCollection RegisterTenanted(this IServiceCollection servic /// /// Registers the for the specified interfaces: /// , , and - /// as per request (scoped), only for services that must be initialized for tenanted services + /// as per request (scoped), only for services that must be initialized for each HTTP request /// public static IServiceCollection RegisterTenanted( this IServiceCollection services, Func implementationFactory) diff --git a/src/Infrastructure.Hosting.Common/Extensions/ServiceProviderExtensions.cs b/src/Infrastructure.Hosting.Common/Extensions/ServiceProviderExtensions.cs index 3a91f28b..295e7579 100644 --- a/src/Infrastructure.Hosting.Common/Extensions/ServiceProviderExtensions.cs +++ b/src/Infrastructure.Hosting.Common/Extensions/ServiceProviderExtensions.cs @@ -5,6 +5,18 @@ namespace Infrastructure.Hosting.Common.Extensions; public static class ServiceProviderExtensions { + /// + /// Returns a function that returns the registered instance of the registered from the + /// container, only for instances that were NOT registered with the + /// . + /// Used to resolve services that are not shared (i.e. neither Platform/Tenanted) + /// + public static Func LazyResolveForUnshared(this IServiceProvider container) + where TService : notnull + { + return () => container.GetRequiredService(); + } + /// /// Returns the registered instance of the registered from the container, /// only for instances that were NOT registered with the @@ -53,6 +65,6 @@ public static TService ResolveForTenant(this IServiceProvider containe public static TService ResolveForUnshared(this IServiceProvider container) where TService : notnull { - return container.Resolve(); + return container.GetRequiredService(); } } \ No newline at end of file diff --git a/src/Infrastructure.Hosting.Common/HostOptions.cs b/src/Infrastructure.Hosting.Common/HostOptions.cs index ea0e242c..a2100c79 100644 --- a/src/Infrastructure.Hosting.Common/HostOptions.cs +++ b/src/Infrastructure.Hosting.Common/HostOptions.cs @@ -6,7 +6,7 @@ public class HostOptions { public static readonly HostOptions BackEndAncillaryApiHost = new("BackendWithAncillaryAPI") { - IsMultiTenanted = false, //TODO: change for multi-tenanted + IsMultiTenanted = true, Persistence = new PersistenceOptions { UsesQueues = true, @@ -16,7 +16,7 @@ public class HostOptions }; public static readonly HostOptions BackEndApiHost = new("BackendAPI") { - IsMultiTenanted = false, //TODO: change for multi-tenanted + IsMultiTenanted = true, Persistence = new PersistenceOptions { UsesQueues = true, @@ -27,7 +27,7 @@ public class HostOptions public static readonly HostOptions BackEndForFrontEndWebHost = new("FrontendSite") { - IsMultiTenanted = false, //TODO: change for multi-tenanted + IsMultiTenanted = false, Persistence = new PersistenceOptions { UsesQueues = false, @@ -38,7 +38,7 @@ public class HostOptions public static readonly HostOptions TestingStubsHost = new("TestingStubs") { - IsMultiTenanted = false, //TODO: change for multi-tenanted + IsMultiTenanted = false, Persistence = new PersistenceOptions { UsesQueues = false, diff --git a/src/Infrastructure.Hosting.Common/Recording/HostRecorder.cs b/src/Infrastructure.Hosting.Common/Recording/HostRecorder.cs index 69a95900..46db1c0e 100644 --- a/src/Infrastructure.Hosting.Common/Recording/HostRecorder.cs +++ b/src/Infrastructure.Hosting.Common/Recording/HostRecorder.cs @@ -219,8 +219,7 @@ public override string ToString() } // ReSharper disable once UnusedParameter.Local - private static ICrashReporter GetCrashReporter(IDependencyContainer container, - ILogger logger, + private static ICrashReporter GetCrashReporter(IDependencyContainer container, ILogger logger, RecordingEnvironmentOptions options) { return options.CrashReporting switch @@ -236,14 +235,13 @@ private static ICrashReporter GetCrashReporter(IDependencyContainer container, }; } - private static IAuditReporter GetAuditReporter(IDependencyContainer container, - RecordingEnvironmentOptions options) + private static IAuditReporter GetAuditReporter(IDependencyContainer container, RecordingEnvironmentOptions options) { return options.AuditReporting switch { AuditReporterOption.None => new NullAuditReporter(), AuditReporterOption.ReliableQueue => new QueuedAuditReporter(container, - container.Resolve().Platform), + container.ResolveForPlatform()), _ => throw new ArgumentOutOfRangeException(nameof(options.MetricReporting)) }; } @@ -265,14 +263,13 @@ private static IMetricReporter GetMetricReporter(IDependencyContainer container, }; } - private static IUsageReporter GetUsageReporter(IDependencyContainer container, - RecordingEnvironmentOptions options) + private static IUsageReporter GetUsageReporter(IDependencyContainer container, RecordingEnvironmentOptions options) { return options.UsageReporting switch { UsageReporterOption.None => new NullUsageReporter(), UsageReporterOption.ReliableQueue => new QueuedUsageReporter(container, - container.Resolve().Platform), + container.ResolveForPlatform()), _ => throw new ArgumentOutOfRangeException(nameof(options.MetricReporting)) }; } diff --git a/src/Infrastructure.Hosting.Common/Resources.Designer.cs b/src/Infrastructure.Hosting.Common/Resources.Designer.cs index 285ed188..0c48aab0 100644 --- a/src/Infrastructure.Hosting.Common/Resources.Designer.cs +++ b/src/Infrastructure.Hosting.Common/Resources.Designer.cs @@ -60,29 +60,56 @@ internal Resources() { } /// - /// Looks up a localized string similar to The setting '{0}' cannot be found in any configuration. + /// Looks up a localized string similar to The setting '{0}' cannot be found in tenant settings nor in platform settings. /// - internal static string AspNetConfigurationSettings_KeyNotFound { + internal static string AspNetDynamicConfigurationSettings_EitherSettings_KeyNotFound { get { - return ResourceManager.GetString("AspNetConfigurationSettings_KeyNotFound", resourceCulture); + return ResourceManager.GetString("AspNetDynamicConfigurationSettings_EitherSettings_KeyNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The setting '{0}' exists, but is not a recognizable number value. + /// + internal static string AspNetDynamicConfigurationSettings_NoTenantSettings { + get { + return ResourceManager.GetString("AspNetDynamicConfigurationSettings_NoTenantSettings", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The setting '{0}' cannot be found in platform settings. + /// + internal static string AspNetDynamicConfigurationSettings_PlatformSettings_KeyNotFound { + get { + return ResourceManager.GetString("AspNetDynamicConfigurationSettings_PlatformSettings_KeyNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The setting '{0}' cannot be found in tenant settings. + /// + internal static string AspNetDynamicConfigurationSettings_TenantSettings_KeyNotFound { + get { + return ResourceManager.GetString("AspNetDynamicConfigurationSettings_TenantSettings_KeyNotFound", resourceCulture); } } /// /// Looks up a localized string similar to The setting '{0}' exists, but is not a recognizable boolean value. /// - internal static string AspNetConfigurationSettings_ValueNotBoolean { + internal static string AspNetDynamicConfigurationSettings_ValueNotBoolean { get { - return ResourceManager.GetString("AspNetConfigurationSettings_ValueNotBoolean", resourceCulture); + return ResourceManager.GetString("AspNetDynamicConfigurationSettings_ValueNotBoolean", resourceCulture); } } /// /// Looks up a localized string similar to The setting '{0}' exists, but is not a recognizable number value. /// - internal static string AspNetConfigurationSettings_ValueNotNumber { + internal static string AspNetDynamicConfigurationSettings_ValueNotNumber { get { - return ResourceManager.GetString("AspNetConfigurationSettings_ValueNotNumber", resourceCulture); + return ResourceManager.GetString("AspNetDynamicConfigurationSettings_ValueNotNumber", resourceCulture); } } diff --git a/src/Infrastructure.Hosting.Common/Resources.resx b/src/Infrastructure.Hosting.Common/Resources.resx index 268f0909..4cd53860 100644 --- a/src/Infrastructure.Hosting.Common/Resources.resx +++ b/src/Infrastructure.Hosting.Common/Resources.resx @@ -1,7 +1,8 @@ - @@ -23,13 +24,22 @@ PublicKeyToken=b77a5c561934e089 - - The setting '{0}' cannot be found in any configuration + + The setting '{0}' cannot be found in platform settings - + + The setting '{0}' cannot be found in tenant settings + + + The setting '{0}' cannot be found in tenant settings nor in platform settings + + The setting '{0}' exists, but is not a recognizable boolean value - + + The setting '{0}' exists, but is not a recognizable number value + + The setting '{0}' exists, but is not a recognizable number value diff --git a/src/Infrastructure.Interfaces/ITenancyContext.cs b/src/Infrastructure.Interfaces/ITenancyContext.cs index 078a1547..57f51e6a 100644 --- a/src/Infrastructure.Interfaces/ITenancyContext.cs +++ b/src/Infrastructure.Interfaces/ITenancyContext.cs @@ -1,3 +1,5 @@ +using Application.Interfaces.Services; + namespace Infrastructure.Interfaces; /// @@ -7,7 +9,7 @@ public interface ITenancyContext { string? Current { get; } - public IReadOnlyDictionary Settings { get; } + public TenantSettings Settings { get; } - void Set(string id, Dictionary settings); + void Set(string id, TenantSettings settings); } \ No newline at end of file diff --git a/src/Infrastructure.Persistence.AWS/ApplicationServices/AWSSQSQueueStore.cs b/src/Infrastructure.Persistence.AWS/ApplicationServices/AWSSQSQueueStore.cs index 820334b7..2292f379 100644 --- a/src/Infrastructure.Persistence.AWS/ApplicationServices/AWSSQSQueueStore.cs +++ b/src/Infrastructure.Persistence.AWS/ApplicationServices/AWSSQSQueueStore.cs @@ -19,7 +19,7 @@ public class AWSSQSQueueStore : IQueueStore private readonly IRecorder _recorder; private readonly IAmazonSQS _serviceClient; - public static AWSSQSQueueStore Create(IRecorder recorder, ISettings settings) + public static AWSSQSQueueStore Create(IRecorder recorder, IConfigurationSettings settings) { var (credentials, regionEndpoint) = settings.GetConnection(); if (regionEndpoint.Exists()) diff --git a/src/Infrastructure.Persistence.AWS/Extensions/SettingsExtensions.cs b/src/Infrastructure.Persistence.AWS/Extensions/SettingsExtensions.cs index 06a46bd7..8ceb5476 100644 --- a/src/Infrastructure.Persistence.AWS/Extensions/SettingsExtensions.cs +++ b/src/Infrastructure.Persistence.AWS/Extensions/SettingsExtensions.cs @@ -8,7 +8,7 @@ namespace Infrastructure.Persistence.AWS.Extensions; public static class SettingsExtensions { public static (BasicAWSCredentials Credentials, RegionEndpoint? RegionEndPoint) GetConnection( - this ISettings settings) + this IConfigurationSettings settings) { var accessKey = settings.GetString(AWSConstants.AccessKeySettingName); var secretKey = settings.GetString(AWSConstants.SecretKeySettingName); diff --git a/src/Infrastructure.Persistence.Azure/ApplicationServices/AzureStorageAccountQueueStore.cs b/src/Infrastructure.Persistence.Azure/ApplicationServices/AzureStorageAccountQueueStore.cs index 7bb67e7c..1cac2ff6 100644 --- a/src/Infrastructure.Persistence.Azure/ApplicationServices/AzureStorageAccountQueueStore.cs +++ b/src/Infrastructure.Persistence.Azure/ApplicationServices/AzureStorageAccountQueueStore.cs @@ -27,7 +27,7 @@ public class AzureStorageAccountQueueStore : IQueueStore private readonly Dictionary _queueExistenceChecks = new(); private readonly IRecorder _recorder; - public static AzureStorageAccountQueueStore Create(IRecorder recorder, ISettings settings) + public static AzureStorageAccountQueueStore Create(IRecorder recorder, IConfigurationSettings settings) { var accountKey = settings.GetString(AccountKeySettingName); var accountName = settings.GetString(AccountNameSettingName); diff --git a/src/Infrastructure.Persistence.Common/ApplicationServices/LocalMachineJsonFileStore.cs b/src/Infrastructure.Persistence.Common/ApplicationServices/LocalMachineJsonFileStore.cs index 436e9813..4094782f 100644 --- a/src/Infrastructure.Persistence.Common/ApplicationServices/LocalMachineJsonFileStore.cs +++ b/src/Infrastructure.Persistence.Common/ApplicationServices/LocalMachineJsonFileStore.cs @@ -22,7 +22,7 @@ public partial class LocalMachineJsonFileStore public const string PathSettingName = "ApplicationServices:Persistence:LocalMachineJsonFileStore:RootPath"; private readonly string _rootPath; - public static LocalMachineJsonFileStore Create(ISettings settings, + public static LocalMachineJsonFileStore Create(IConfigurationSettings settings, IQueueStoreNotificationHandler? handler = default) { var configPath = settings.GetString(PathSettingName); diff --git a/src/Infrastructure.Persistence.Shared.IntegrationTests/AnyDataStoreBaseSpec.cs b/src/Infrastructure.Persistence.Shared.IntegrationTests/AnyDataStoreBaseSpec.cs index 4e1764e0..4798e6ae 100644 --- a/src/Infrastructure.Persistence.Shared.IntegrationTests/AnyDataStoreBaseSpec.cs +++ b/src/Infrastructure.Persistence.Shared.IntegrationTests/AnyDataStoreBaseSpec.cs @@ -3,7 +3,7 @@ using Domain.Common.ValueObjects; using Domain.Interfaces; using FluentAssertions; -using Infrastructure.Common; +using Infrastructure.Hosting.Common; using Infrastructure.Persistence.Interfaces; using Microsoft.Extensions.DependencyInjection; using QueryAny; diff --git a/src/Infrastructure.Persistence.Shared.IntegrationTests/StoreSpecSetupBase.cs b/src/Infrastructure.Persistence.Shared.IntegrationTests/StoreSpecSetupBase.cs index 8ead6e4d..b9c8b6c5 100644 --- a/src/Infrastructure.Persistence.Shared.IntegrationTests/StoreSpecSetupBase.cs +++ b/src/Infrastructure.Persistence.Shared.IntegrationTests/StoreSpecSetupBase.cs @@ -11,8 +11,8 @@ protected StoreSpecSetupBase() var configuration = new ConfigurationBuilder() .AddJsonFile(@"appsettings.Testing.json") .Build(); - Settings = new AspNetConfigurationSettings(configuration).Platform; + Settings = new AspNetDynamicConfigurationSettings(configuration); } - protected ISettings Settings { get; } + protected IConfigurationSettings Settings { get; } } \ No newline at end of file diff --git a/src/Infrastructure.Persistence.Shared/ApplicationServices/ProvisioningMessageQueue.cs b/src/Infrastructure.Persistence.Shared/ApplicationServices/ProvisioningMessageQueue.cs new file mode 100644 index 00000000..33207f52 --- /dev/null +++ b/src/Infrastructure.Persistence.Shared/ApplicationServices/ProvisioningMessageQueue.cs @@ -0,0 +1,47 @@ +using Application.Persistence.Interfaces; +using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; +using Common; +using Domain.Interfaces; +using Infrastructure.Persistence.Common; +using Infrastructure.Persistence.Interfaces; + +namespace Infrastructure.Persistence.Shared.ApplicationServices; + +public class ProvisioningMessageQueue : IProvisioningMessageQueue +{ + private readonly MessageQueueStore _messageQueue; + + public ProvisioningMessageQueue(IRecorder recorder, IMessageQueueIdFactory messageQueueIdFactory, IQueueStore store) + { + _messageQueue = new MessageQueueStore(recorder, messageQueueIdFactory, store); + } + + public Task> CountAsync(CancellationToken cancellationToken) + { + return _messageQueue.CountAsync(cancellationToken); + } + + public Task> DestroyAllAsync(CancellationToken cancellationToken) + { + return _messageQueue.DestroyAllAsync(cancellationToken); + } + + public Task> PopSingleAsync( + Func>> onMessageReceivedAsync, + CancellationToken cancellationToken) + { + return _messageQueue.PopSingleAsync(onMessageReceivedAsync, cancellationToken); + } + + public Task> PushAsync(ICallContext call, ProvisioningMessage message, + CancellationToken cancellationToken) + { + return _messageQueue.PushAsync(call, message, cancellationToken); + } + + Task> IApplicationRepository.DestroyAllAsync(CancellationToken cancellationToken) + { + return DestroyAllAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Common.UnitTests/ApiUsageFilterSpec.cs b/src/Infrastructure.Web.Api.Common.UnitTests/Endpoints/ApiUsageFilterSpec.cs similarity index 98% rename from src/Infrastructure.Web.Api.Common.UnitTests/ApiUsageFilterSpec.cs rename to src/Infrastructure.Web.Api.Common.UnitTests/Endpoints/ApiUsageFilterSpec.cs index c72ef8f7..8fab2d4a 100644 --- a/src/Infrastructure.Web.Api.Common.UnitTests/ApiUsageFilterSpec.cs +++ b/src/Infrastructure.Web.Api.Common.UnitTests/Endpoints/ApiUsageFilterSpec.cs @@ -2,13 +2,14 @@ using Common; using FluentAssertions; using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common.Endpoints; using Infrastructure.Web.Api.Operations.Shared.Health; using Infrastructure.Web.Api.Operations.Shared.TestingOnly; using Microsoft.AspNetCore.Http; using Moq; using Xunit; -namespace Infrastructure.Web.Api.Common.UnitTests; +namespace Infrastructure.Web.Api.Common.UnitTests.Endpoints; [Trait("Category", "Unit")] public class ApiUsageFilterSpec diff --git a/src/Infrastructure.Web.Api.Common.UnitTests/ContentNegotiationFilterSpec.cs b/src/Infrastructure.Web.Api.Common.UnitTests/Endpoints/ContentNegotiationFilterSpec.cs similarity index 99% rename from src/Infrastructure.Web.Api.Common.UnitTests/ContentNegotiationFilterSpec.cs rename to src/Infrastructure.Web.Api.Common.UnitTests/Endpoints/ContentNegotiationFilterSpec.cs index d807fa41..5c79212d 100644 --- a/src/Infrastructure.Web.Api.Common.UnitTests/ContentNegotiationFilterSpec.cs +++ b/src/Infrastructure.Web.Api.Common.UnitTests/Endpoints/ContentNegotiationFilterSpec.cs @@ -2,6 +2,7 @@ using System.Text; using Common.Extensions; using FluentAssertions; +using Infrastructure.Web.Api.Common.Endpoints; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; @@ -12,7 +13,7 @@ using JsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions; using Task = System.Threading.Tasks.Task; -namespace Infrastructure.Web.Api.Common.UnitTests; +namespace Infrastructure.Web.Api.Common.UnitTests.Endpoints; [Trait("Category", "Unit")] public class ContentNegotiationFilterSpec diff --git a/src/Infrastructure.Web.Api.Common.UnitTests/Endpoints/EndpointTestingAssertions.cs b/src/Infrastructure.Web.Api.Common.UnitTests/Endpoints/EndpointTestingAssertions.cs new file mode 100644 index 00000000..2911ebac --- /dev/null +++ b/src/Infrastructure.Web.Api.Common.UnitTests/Endpoints/EndpointTestingAssertions.cs @@ -0,0 +1,75 @@ +using System.Net; +using Common.Extensions; +using FluentAssertions; +using FluentAssertions.Execution; +using FluentAssertions.Primitives; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace Infrastructure.Web.Api.Common.UnitTests.Endpoints; + +internal static class ResultExtensions +{ + public static EndpointTestingAssertions Should(this ProblemHttpResult instance) + { + return new EndpointTestingAssertions(instance); + } +} + +internal class EndpointTestingAssertions : ObjectAssertions +{ + public EndpointTestingAssertions(ProblemHttpResult instance) : base(instance) + { + } + + protected override string Identifier => "response"; + + public AndConstraint BeAProblem(HttpStatusCode status, string? detail, + string? message = null, string because = "", params object[] becauseArgs) + { + ProblemHttpResult? problemHttpResult = null; + Execute.Assertion + .BecauseOf(because, becauseArgs) + .Given(() => Subject) + .ForCondition(response => + { + problemHttpResult = GetProblemResult(response); + if (problemHttpResult.NotExists()) + { + return false; + } + + return problemHttpResult.ProblemDetails.Detail == detail; + }) + .FailWith( + "Expected {context:response} to be a ProblemDetails containing Detail {0}{reason}, but found {1}.", + _ => detail, _ => problemHttpResult.Exists() + ? problemHttpResult.ProblemDetails.Detail + : "a different kind of response") + .Then + .ForCondition(_ => + { + if (problemHttpResult.NotExists()) + { + return false; + } + + return problemHttpResult.StatusCode == (int)status; + }) + .FailWith("Expected {context:response} to have status code {0}{reason}, but found {1}.", + _ => status, _ => problemHttpResult.Exists() + ? problemHttpResult.StatusCode + : "a different status code"); + + return new AndConstraint(this); + } + + private static ProblemHttpResult? GetProblemResult(object? instance) + { + if (instance is ProblemHttpResult result) + { + return result; + } + + return null; + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Common.UnitTests/Endpoints/MultiTenancyFilterSpec.cs b/src/Infrastructure.Web.Api.Common.UnitTests/Endpoints/MultiTenancyFilterSpec.cs new file mode 100644 index 00000000..f354fc61 --- /dev/null +++ b/src/Infrastructure.Web.Api.Common.UnitTests/Endpoints/MultiTenancyFilterSpec.cs @@ -0,0 +1,149 @@ +using FluentAssertions; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common.Endpoints; +using Infrastructure.Web.Api.Interfaces; +using Microsoft.AspNetCore.Http; +using Moq; +using Xunit; + +namespace Infrastructure.Web.Api.Common.UnitTests.Endpoints; + +[Trait("Category", "Unit")] +public class MultiTenancyFilterSpec +{ + private readonly MultiTenancyFilter _filter; + private readonly Mock _next; + private readonly Mock _serviceProvider; + private readonly Mock _tenancyContext; + + public MultiTenancyFilterSpec() + { + _tenancyContext = new Mock(); + _serviceProvider = new Mock(); + _serviceProvider.Setup(sp => sp.GetService(typeof(ITenancyContext))) + .Returns(_tenancyContext.Object); + _next = new Mock(); + _filter = new MultiTenancyFilter(); + } + + [Fact] + public async Task WhenInvokeAndWrongRequestDelegateSignature_ThenContinuesPipeline() + { + var httpContext = new DefaultHttpContext + { + RequestServices = _serviceProvider.Object + }; + var context = new DefaultEndpointFilterInvocationContext(httpContext); + + await _filter.InvokeAsync(context, _next.Object); + + _next.Verify(n => n.Invoke(context)); + } + + [Fact] + public async Task WhenInvokeAndUnTenantedRequestInDelegateSignature_ThenContinuesPipeline() + { + var httpContext = new DefaultHttpContext + { + RequestServices = _serviceProvider.Object + }; + var args = new object[] { new { }, new TestUnTenantedRequest() }; + var context = new DefaultEndpointFilterInvocationContext(httpContext, args); + + await _filter.InvokeAsync(context, _next.Object); + + _next.Verify(n => n.Invoke(context)); + } + + [Fact] + public async Task WhenInvokeAndNoTenantInTenancyContext_ThenContinuesPipeline() + { + var httpContext = new DefaultHttpContext + { + RequestServices = _serviceProvider.Object + }; + var args = new object[] { new { }, new TestTenantedRequest() }; + var context = new DefaultEndpointFilterInvocationContext(httpContext, args); + + await _filter.InvokeAsync(context, _next.Object); + + _next.Verify(n => n.Invoke(context)); + context.Arguments[1].As().OrganizationId.Should().BeNull(); + } + + [Fact] + public async Task WhenInvokeAndEmptyTenantInTenancyContext_ThenContinuesPipeline() + { + _tenancyContext.Setup(tc => tc.Current) + .Returns(string.Empty); + var httpContext = new DefaultHttpContext + { + RequestServices = _serviceProvider.Object + }; + var args = new object[] { new { }, new TestTenantedRequest() }; + var context = new DefaultEndpointFilterInvocationContext(httpContext, args); + + await _filter.InvokeAsync(context, _next.Object); + + _next.Verify(n => n.Invoke(context)); + context.Arguments[1].As().OrganizationId.Should().BeNull(); + } + + [Fact] + public async Task WhenInvokeAndTenantIdInRequestAlreadyPopulated_ThenContinuesPipeline() + { + _tenancyContext.Setup(tc => tc.Current) + .Returns("atenantid"); + var httpContext = new DefaultHttpContext + { + RequestServices = _serviceProvider.Object + }; + var args = new object[] + { + new { }, new TestTenantedRequest + { + OrganizationId = "anorganizationid" + } + }; + var context = new DefaultEndpointFilterInvocationContext(httpContext, args); + + await _filter.InvokeAsync(context, _next.Object); + + _next.Verify(n => n.Invoke(context)); + context.Arguments[1].As().OrganizationId.Should().Be("anorganizationid"); + } + + [Fact] + public async Task WhenInvokeAndTenantIdInRequestIsEmpty_ThenPopulatesAndContinuesPipeline() + { + _tenancyContext.Setup(tc => tc.Current) + .Returns("atenantid"); + var httpContext = new DefaultHttpContext + { + RequestServices = _serviceProvider.Object + }; + + var args = new object[] + { + new { }, new TestTenantedRequest + { + OrganizationId = null + } + }; + var context = new DefaultEndpointFilterInvocationContext(httpContext, args); + + await _filter.InvokeAsync(context, _next.Object); + + _next.Verify(n => n.Invoke(context)); + context.Arguments[1].As().OrganizationId.Should().Be("atenantid"); + } + + private class TestUnTenantedRequest : IWebRequest + { + } + + private class TestTenantedRequest : IWebRequest, ITenantedRequest + { + public string? OrganizationId { get; set; } + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Common.UnitTests/RequestCorrelationFilterSpec.cs b/src/Infrastructure.Web.Api.Common.UnitTests/Endpoints/RequestCorrelationFilterSpec.cs similarity index 95% rename from src/Infrastructure.Web.Api.Common.UnitTests/RequestCorrelationFilterSpec.cs rename to src/Infrastructure.Web.Api.Common.UnitTests/Endpoints/RequestCorrelationFilterSpec.cs index 8f203249..ed588a21 100644 --- a/src/Infrastructure.Web.Api.Common.UnitTests/RequestCorrelationFilterSpec.cs +++ b/src/Infrastructure.Web.Api.Common.UnitTests/Endpoints/RequestCorrelationFilterSpec.cs @@ -1,8 +1,9 @@ using FluentAssertions; +using Infrastructure.Web.Api.Common.Endpoints; using Microsoft.AspNetCore.Http; using Xunit; -namespace Infrastructure.Web.Api.Common.UnitTests; +namespace Infrastructure.Web.Api.Common.UnitTests.Endpoints; [Trait("Category", "Unit")] public class RequestCorrelationFilterSpec diff --git a/src/Infrastructure.Web.Api.Common.UnitTests/RequestTenantDetectiveSpec.cs b/src/Infrastructure.Web.Api.Common.UnitTests/RequestTenantDetectiveSpec.cs new file mode 100644 index 00000000..21acf594 --- /dev/null +++ b/src/Infrastructure.Web.Api.Common.UnitTests/RequestTenantDetectiveSpec.cs @@ -0,0 +1,195 @@ +using System.Text; +using Common; +using Common.Extensions; +using FluentAssertions; +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using UnitTesting.Common; +using Xunit; + +namespace Infrastructure.Web.Api.Common.UnitTests; + +[UsedImplicitly] +public class RequestTenantDetectiveSpec +{ + [Trait("Category", "Unit")] + public class GivenAnUntenantedRequestDto + { + private readonly RequestTenantDetective _detective; + + public GivenAnUntenantedRequestDto() + { + _detective = new RequestTenantDetective(); + } + + [Fact] + public async Task WhenDetectTenantAsyncAndNoRequestType_ThenReturnsNoTenantId() + { + var httpContext = new DefaultHttpContext + { + Request = + { + Method = HttpMethods.Get + } + }; + + var result = + await _detective.DetectTenantAsync(httpContext, Optional.None, CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.TenantId.Should().BeNone(); + result.Value.ShouldHaveTenantId.Should().BeFalse(); + } + + [Fact] + public async Task WhenDetectTenantAsyncButNoHeaderQueryOrBody_ThenReturnsNoTenantId() + { + var httpContext = new DefaultHttpContext + { + Request = + { + Method = HttpMethods.Get + } + }; + + var result = + await _detective.DetectTenantAsync(httpContext, typeof(TestUnTenantedRequest), CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.TenantId.Should().BeNone(); + result.Value.ShouldHaveTenantId.Should().BeFalse(); + } + + [Fact] + public async Task WhenDetectTenantAsyncAndTenantIdInHeader_ThenReturnsTenantId() + { + var httpContext = new DefaultHttpContext + { + Request = + { + Headers = + { + { HttpHeaders.Tenant, "atenantid" } + } + } + }; + + var result = + await _detective.DetectTenantAsync(httpContext, typeof(TestUnTenantedRequest), CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.TenantId.Should().Be("atenantid"); + result.Value.ShouldHaveTenantId.Should().BeFalse(); + } + + [Fact] + public async Task WhenDetectTenantAsyncAndTenantIdInQueryString_ThenReturnsTenantId() + { + var httpContext = new DefaultHttpContext + { + Request = + { + Query = new QueryCollection(new Dictionary + { + { nameof(ITenantedRequest.OrganizationId), "atenantid" } + }), + Method = HttpMethods.Get + } + }; + + var result = + await _detective.DetectTenantAsync(httpContext, typeof(TestUnTenantedRequest), CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.TenantId.Should().Be("atenantid"); + result.Value.ShouldHaveTenantId.Should().BeFalse(); + } + + [Fact] + public async Task WhenDetectTenantAsyncAndTenantIdInJsonBody_ThenReturnsTenantId() + { + var httpContext = new DefaultHttpContext + { + Request = + { + Method = HttpMethods.Post, + ContentType = HttpContentTypes.Json, + Body = new MemoryStream(Encoding.UTF8.GetBytes(new + { + OrganizationId = "atenantid" + }.ToJson()!)) + } + }; + + var result = + await _detective.DetectTenantAsync(httpContext, typeof(TestUnTenantedRequest), CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.TenantId.Should().Be("atenantid"); + result.Value.ShouldHaveTenantId.Should().BeFalse(); + } + + [Fact] + public async Task WhenDetectTenantAsyncAndTenantIdInFormUrlEncodedBody_ThenReturnsTenantId() + { + var httpContext = new DefaultHttpContext + { + Request = + { + Method = HttpMethods.Post, + ContentType = HttpContentTypes.FormUrlEncoded, + Body = await new FormUrlEncodedContent(new List> + { + new(nameof(ITenantedRequest.OrganizationId), "atenantid") + }).ReadAsStreamAsync() + } + }; + + var result = + await _detective.DetectTenantAsync(httpContext, typeof(TestUnTenantedRequest), CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.TenantId.Should().Be("atenantid"); + result.Value.ShouldHaveTenantId.Should().BeFalse(); + } + } + + [Trait("Category", "Unit")] + public class GivenATenantedRequestDto + { + private readonly RequestTenantDetective _detective; + + public GivenATenantedRequestDto() + { + _detective = new RequestTenantDetective(); + } + + [Fact] + public async Task WhenDetectTenantAsyncButNoHeaderQueryOrBody_ThenReturnsNoTenantId() + { + var httpContext = new DefaultHttpContext + { + Request = { Method = HttpMethods.Get } + }; + + var result = + await _detective.DetectTenantAsync(httpContext, typeof(TestTenantedRequest), CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.TenantId.Should().BeNone(); + result.Value.ShouldHaveTenantId.Should().BeTrue(); + } + } + + [UsedImplicitly] + public class TestUnTenantedRequest : UnTenantedRequest + { + } + + [UsedImplicitly] + public class TestTenantedRequest : TenantedRequest + { + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Common/ApiUsageFilter.cs b/src/Infrastructure.Web.Api.Common/Endpoints/ApiUsageFilter.cs similarity index 97% rename from src/Infrastructure.Web.Api.Common/ApiUsageFilter.cs rename to src/Infrastructure.Web.Api.Common/Endpoints/ApiUsageFilter.cs index 8477aeeb..20842f2c 100644 --- a/src/Infrastructure.Web.Api.Common/ApiUsageFilter.cs +++ b/src/Infrastructure.Web.Api.Common/Endpoints/ApiUsageFilter.cs @@ -13,7 +13,7 @@ using RecordMeasureRequest = Infrastructure.Web.Api.Operations.Shared.Ancillary.RecordMeasureRequest; using RecordUseRequest = Infrastructure.Web.Api.Operations.Shared.Ancillary.RecordUseRequest; -namespace Infrastructure.Web.Api.Common; +namespace Infrastructure.Web.Api.Common.Endpoints; /// /// Provides a request filter that captures usage of the current request @@ -59,12 +59,12 @@ public ApiUsageFilter(IRecorder recorder, ICallerContextFactory callerContextFac var stopwatch = Stopwatch.StartNew(); TrackRequest(context, caller); - var result = await next(context); + var response = await next(context); //Continue down the pipeline stopwatch.Stop(); TrackResponse(context, caller, stopwatch.Elapsed); - return result; + return response; } private void TrackRequest(EndpointFilterInvocationContext context, ICallerContext caller) diff --git a/src/Infrastructure.Web.Api.Common/ContentNegotiationFilter.cs b/src/Infrastructure.Web.Api.Common/Endpoints/ContentNegotiationFilter.cs similarity index 96% rename from src/Infrastructure.Web.Api.Common/ContentNegotiationFilter.cs rename to src/Infrastructure.Web.Api.Common/Endpoints/ContentNegotiationFilter.cs index b35c9e4b..0f7b68aa 100644 --- a/src/Infrastructure.Web.Api.Common/ContentNegotiationFilter.cs +++ b/src/Infrastructure.Web.Api.Common/Endpoints/ContentNegotiationFilter.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Primitives; -namespace Infrastructure.Web.Api.Common; +namespace Infrastructure.Web.Api.Common.Endpoints; /// /// Provides a response filter that outputs the response in any of these: @@ -19,8 +19,7 @@ public class ContentNegotiationFilter : IEndpointFilter { public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { - // Complete the request and response pipelines - var response = await next(context); + var response = await next(context); //Continue down the pipeline if (response is null) { return response; @@ -169,7 +168,10 @@ public class ContentNegotiationFilter : IEndpointFilter private static async Task GetFormatInJsonPayloadAsync(HttpRequest httpRequest, CancellationToken cancellationToken) { - httpRequest.RewindBody(); + if (httpRequest.Body.Position != 0) + { + httpRequest.RewindBody(); + } try { var requestWithFormat = @@ -178,6 +180,8 @@ private static async Task GetFormatInJsonPayloadAsync(HttpRequest { return new StringValues(requestWithFormat.Format); } + + httpRequest.RewindBody(); } catch (JsonException) { @@ -249,6 +253,7 @@ private enum NegotiatedMimeType Xml } + // ReSharper disable once MemberCanBePrivate.Global internal class RequestWithFormat { public string? Format { get; set; } diff --git a/src/Infrastructure.Web.Api.Common/Endpoints/MultiTenancyFilter.cs b/src/Infrastructure.Web.Api.Common/Endpoints/MultiTenancyFilter.cs new file mode 100644 index 00000000..35f24276 --- /dev/null +++ b/src/Infrastructure.Web.Api.Common/Endpoints/MultiTenancyFilter.cs @@ -0,0 +1,86 @@ +using Common; +using Common.Extensions; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Infrastructure.Web.Api.Common.Endpoints; + +/// +/// Provides a request filter that rewrites the tenant ID into request argument +/// of the current of the current EndPoint +/// +public class MultiTenancyFilter : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var tenancyContext = context.HttpContext.RequestServices.GetRequiredService(); + var cancellationToken = context.HttpContext.RequestAborted; + + var result = await ModifyRequestAsync(context, tenancyContext, cancellationToken); + if (!result.IsSuccessful) + { + var httpError = result.Error.ToHttpError(); + return Results.Problem(statusCode: (int)httpError.Code, detail: httpError.Message); + } + + return await next(context); //Continue down the pipeline + } + + // ReSharper disable once UnusedParameter.Local + private static async Task> ModifyRequestAsync( + EndpointFilterInvocationContext filterContext, ITenancyContext tenancyContext, + CancellationToken cancellationToken + ) + { + await Task.CompletedTask; + var requestDto = GetRequestDtoFromEndpoint(filterContext); + if (!requestDto.HasValue) + { + return Result.Ok; + } + + if (requestDto.Value is not ITenantedRequest tenantedRequest) + { + return Result.Ok; + } + + var tenantId = tenancyContext.Current; + if (tenantId.NotExists()) + { + return Result.Ok; + } + + var organizationId = tenantId; + if (organizationId.HasNoValue()) + { + return Result.Ok; + } + + if (tenantedRequest.OrganizationId.HasNoValue()) + { + tenantedRequest.OrganizationId = organizationId; + } + + return Result.Ok; + } + + private static Optional GetRequestDtoFromEndpoint(EndpointFilterInvocationContext filterContext) + { + var requestHandlerParameters = filterContext.Arguments; + if (requestHandlerParameters.Count != 2) + { + return Optional.None; + } + + var requestDto = filterContext.Arguments[1]; + if (requestDto is IWebRequest webRequest) + { + return webRequest.ToOptional(); + } + + return Optional.None; + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Common/RequestCorrelationFilter.cs b/src/Infrastructure.Web.Api.Common/Endpoints/RequestCorrelationFilter.cs similarity index 94% rename from src/Infrastructure.Web.Api.Common/RequestCorrelationFilter.cs rename to src/Infrastructure.Web.Api.Common/Endpoints/RequestCorrelationFilter.cs index eb7068d0..befa985b 100644 --- a/src/Infrastructure.Web.Api.Common/RequestCorrelationFilter.cs +++ b/src/Infrastructure.Web.Api.Common/Endpoints/RequestCorrelationFilter.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; -namespace Infrastructure.Web.Api.Common; +namespace Infrastructure.Web.Api.Common.Endpoints; /// /// Provides a request/response filter that accepts (or creates) a request correlation ID, @@ -28,8 +28,7 @@ public class RequestCorrelationFilter : IEndpointFilter SaveToRequestPipeline(context.HttpContext, correlationId); } - // Complete the request and response pipelines - var response = await next(context); + var response = await next(context); //Continue down the pipeline SetOnResponse(context.HttpContext, correlationId); diff --git a/src/Infrastructure.Web.Api.Common/Extensions/HttpRequestExtensions.cs b/src/Infrastructure.Web.Api.Common/Extensions/HttpRequestExtensions.cs index 3e14f70b..d53c712c 100644 --- a/src/Infrastructure.Web.Api.Common/Extensions/HttpRequestExtensions.cs +++ b/src/Infrastructure.Web.Api.Common/Extensions/HttpRequestExtensions.cs @@ -12,6 +12,14 @@ public static class HttpRequestExtensions { public const string BearerTokenPrefix = "Bearer"; + /// + /// Whether the specified could have a content body. + /// + public static bool CanHaveBody(this HttpMethod method) + { + return method == HttpMethod.Post || method == HttpMethod.Put || method == HttpMethod.Patch; + } + /// /// Returns the value of the APIKEY authorization from the request (if any) /// @@ -229,11 +237,16 @@ public static void SetRequestId(this HttpRequestMessage message, ICallContext co /// /// Whether the specified HMAC signature represents the signature of the contents of the inbound request, - /// signed by the method + /// signed by the method /// public static async Task VerifyHMACSignatureAsync(this HttpRequest request, string signature, string secret, CancellationToken cancellationToken) { + if (request.Body.Position != 0) + { + request.RewindBody(); + } + var body = await request.Body.ReadFullyAsync(cancellationToken); request.RewindBody(); // HACK: need to do this for later middleware diff --git a/src/Infrastructure.Web.Api.Common/Extensions/HttpResponseExtensions.cs b/src/Infrastructure.Web.Api.Common/Extensions/HttpResponseExtensions.cs index 04044943..b8369eb6 100644 --- a/src/Infrastructure.Web.Api.Common/Extensions/HttpResponseExtensions.cs +++ b/src/Infrastructure.Web.Api.Common/Extensions/HttpResponseExtensions.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Application.Common; using Common.Extensions; +using Infrastructure.Web.Api.Common.Endpoints; using Microsoft.AspNetCore.Mvc; namespace Infrastructure.Web.Api.Common.Extensions; diff --git a/src/Infrastructure.Web.Api.Common/HttpConstants.cs b/src/Infrastructure.Web.Api.Common/HttpConstants.cs index 1b3a2039..475a1876 100644 --- a/src/Infrastructure.Web.Api.Common/HttpConstants.cs +++ b/src/Infrastructure.Web.Api.Common/HttpConstants.cs @@ -1,3 +1,5 @@ +using Infrastructure.Web.Api.Interfaces; + namespace Infrastructure.Web.Api.Common; /// @@ -8,15 +10,15 @@ public static class HttpContentTypes public const string FormData = "multipart/form-data"; public const string FormUrlEncoded = "application/x-www-form-urlencoded"; public const string FormUrlEncodedWithCharset = "application/x-www-form-urlencoded; charset=utf-8"; + public const string Html = "text/html"; public const string Json = "application/json"; public const string JsonProblem = "application/problem+json"; public const string JsonWithCharset = "application/json; charset=utf-8"; public const string OctetStream = "application/octet-stream"; + public const string Text = "text/plain"; public const string Xml = "application/xml"; public const string XmlProblem = "application/problem+xml"; public const string XmlWithCharset = "application/xml; charset=utf-8"; - public const string Html = "text/html"; - public const string Text = "text/plain"; } /// @@ -25,14 +27,15 @@ public static class HttpContentTypes public static class HttpHeaders { public const string Accept = "Accept"; + public const string AntiCSRF = "anti-csrf-tok"; public const string Authorization = "Authorization"; public const string ContentType = "Content-Type"; public const string HMACSignature = "X-Hub-Signature"; - public const string RequestId = "Request-ID"; - public const string AntiCSRF = "anti-csrf-tok"; public const string Origin = "Origin"; public const string Referer = "Referer"; + public const string RequestId = "Request-ID"; public const string SetCookie = "Set-Cookie"; + public const string Tenant = "Tenant"; } /// @@ -42,6 +45,7 @@ public static class HttpQueryParams { public const string APIKey = "apikey"; public const string Format = "format"; + public const string Tenant = nameof(ITenantedRequest.OrganizationId); } /// diff --git a/src/Infrastructure.Web.Api.Common/Infrastructure.Web.Api.Common.csproj b/src/Infrastructure.Web.Api.Common/Infrastructure.Web.Api.Common.csproj index aef9c90f..243d6e57 100644 --- a/src/Infrastructure.Web.Api.Common/Infrastructure.Web.Api.Common.csproj +++ b/src/Infrastructure.Web.Api.Common/Infrastructure.Web.Api.Common.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Infrastructure.Web.Api.Common/RequestTenantDetective.cs b/src/Infrastructure.Web.Api.Common/RequestTenantDetective.cs new file mode 100644 index 00000000..8a353e83 --- /dev/null +++ b/src/Infrastructure.Web.Api.Common/RequestTenantDetective.cs @@ -0,0 +1,144 @@ +using System.Text.Json; +using Common; +using Common.Extensions; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace Infrastructure.Web.Api.Common; + +/// +/// Provides a detective that determines the tenant of the request from data within the request, +/// in either from the field in the body, +/// from the query string or from the header. +/// +public class RequestTenantDetective : ITenantDetective +{ + public async Task> DetectTenantAsync(HttpContext httpContext, + Optional requestDtoType, CancellationToken cancellationToken) + { + var shouldHaveTenantId = IsTenantedRequest(requestDtoType); + var (found, tenantIdFromRequest) = await ParseTenantIdFromRequestAsync(httpContext.Request, cancellationToken); + if (found) + { + return new TenantDetectionResult(shouldHaveTenantId, tenantIdFromRequest); + } + + return new TenantDetectionResult(shouldHaveTenantId, null); + } + + private static bool IsTenantedRequest(Optional requestDtoType) + { + if (!requestDtoType.HasValue) + { + return false; + } + + return requestDtoType.Value.IsAssignableTo(typeof(ITenantedRequest)); + } + + /// + /// Attempts to locate the tenant ID from the request query, or header, or body + /// + private static async Task<(bool HasTenantId, string? tenantId)> ParseTenantIdFromRequestAsync(HttpRequest request, + CancellationToken cancellationToken) + { + if (request.Headers.TryGetValue(HttpHeaders.Tenant, out var tenantIdFromHeader)) + { + var value = GetFirstStringValue(tenantIdFromHeader); + if (value.HasValue()) + { + return (true, value); + } + } + + if (request.Query.TryGetValue(HttpQueryParams.Tenant, out var tenantIdFromQueryString)) + { + var value = GetFirstStringValue(tenantIdFromQueryString); + if (value.HasValue()) + { + return (true, value); + } + } + + var couldHaveBody = new HttpMethod(request.Method).CanHaveBody(); + if (couldHaveBody) + { + var (found, tenantIdFromRequestBody) = await ParseTenantIdFromRequestBodyAsync(request, cancellationToken); + if (found) + { + return (true, tenantIdFromRequestBody); + } + } + + return (false, null); + } + + private static async Task<(bool HasTenantId, string? tenantId)> ParseTenantIdFromRequestBodyAsync( + HttpRequest request, CancellationToken cancellationToken) + { + if (request.Body.Position != 0) + { + request.RewindBody(); + } + + if (request.ContentType == HttpContentTypes.Json) + { + try + { + var requestWithTenantId = + await request.ReadFromJsonAsync(typeof(RequestWithTenantId), cancellationToken); + request.RewindBody(); + if (requestWithTenantId is RequestWithTenantId tenantId) + { + if (tenantId.OrganizationId.HasValue()) + { + return (true, tenantId.OrganizationId); + } + + if (tenantId.TenantId.HasValue()) + { + return (true, tenantId.TenantId); + } + } + } + catch (JsonException) + { + return (false, null); + } + } + + if (request.ContentType == HttpContentTypes.FormUrlEncoded) + { + var form = await request.ReadFormAsync(cancellationToken); + if (form.TryGetValue(nameof(ITenantedRequest.OrganizationId), out var tenantId)) + { + var value = GetFirstStringValue(tenantId); + if (value.HasValue()) + { + return (true, value); + } + } + } + + return (false, null); + } + + private static string? GetFirstStringValue(StringValues values) + { + return values.FirstOrDefault(value => value.HasValue()); + } + + /// + /// Defines a request that could have a tenant ID within it + /// + // ReSharper disable once MemberCanBePrivate.Global + internal class RequestWithTenantId : ITenantedRequest + { + public string? TenantId { get; [UsedImplicitly] set; } + + public string? OrganizationId { get; set; } + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.IntegrationTests/MultiTenancySpec.cs b/src/Infrastructure.Web.Api.IntegrationTests/MultiTenancySpec.cs new file mode 100644 index 00000000..00973f0c --- /dev/null +++ b/src/Infrastructure.Web.Api.IntegrationTests/MultiTenancySpec.cs @@ -0,0 +1,217 @@ +using System.Net; +using ApiHost1; +using Application.Resources.Shared; +using CarsDomain; +using Common.Extensions; +using FluentAssertions; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.IntegrationTests.Stubs; +using Infrastructure.Web.Api.Operations.Shared.Cars; +using Infrastructure.Web.Api.Operations.Shared.Organizations; +using Infrastructure.Web.Hosting.Common; +using IntegrationTesting.WebApi.Common; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +#if TESTINGONLY +using Application.Interfaces.Services; +using Common.Configuration; +using Infrastructure.Hosting.Common.Extensions; +using Infrastructure.Persistence.Common.ApplicationServices; +using Infrastructure.Persistence.Interfaces; +using Infrastructure.Persistence.Interfaces.ApplicationServices; +using QueryAny; +using Car = CarsApplication.Persistence.ReadModels.Car; +#endif + +namespace Infrastructure.Web.Api.IntegrationTests; + +[Trait("Category", "Integration.Web")] +[Collection("API")] +public class MultiTenancySpec : WebApiSpec +{ + public MultiTenancySpec(WebApiSetup setup) : base(setup, OverrideDependencies) + { + EmptyAllRepositories(); + DeleteAllPreviousTenants(StubTenantSettingsService.GetRepositoryPath(null)); + } + + [Fact] + public async Task WhenOnePersonAccessesAnotherPersonsOrganization_ThenForbidden() + { + var loginB = await LoginUserAsync(LoginUser.PersonB); + var loginBOrgId = loginB.User.Profile!.DefaultOrganizationId!; + + var loginA = await LoginUserAsync(); + var result = await Api.GetAsync(new SearchAllCarsRequest + { + OrganizationId = loginBOrgId + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + result.StatusCode.Should().Be(HttpStatusCode.Forbidden); + result.Content.Error.Detail.Should().Be(Resources.MultiTenancyMiddleware_UserNotAMember.Format(loginBOrgId)); + } + + [Fact] + public async Task WhenOnePersonAccessesAnotherPersonsOrganizationVisaVersa_ThenForbidden() + { + var loginA = await LoginUserAsync(); + var loginAOrgId = loginA.User.Profile!.DefaultOrganizationId!; + + var loginB = await LoginUserAsync(LoginUser.PersonB); + var result = await Api.GetAsync(new SearchAllCarsRequest + { + OrganizationId = loginAOrgId + }, req => req.SetJWTBearerToken(loginB.AccessToken)); + + result.StatusCode.Should().Be(HttpStatusCode.Forbidden); + result.Content.Error.Detail.Should().Be(Resources.MultiTenancyMiddleware_UserNotAMember.Format(loginAOrgId)); + } + + [Fact] + public async Task WhenCreateTenantedDataToPhysicalTenantStores_ThenReturnsTenantedData() + { + var loginA = await LoginUserAsync(); + var organization1Id = loginA.User.Profile!.DefaultOrganizationId!; + var organization1 = (await Api.GetAsync(new GetOrganizationRequest + { + Id = organization1Id + }, req => req.SetJWTBearerToken(loginA.AccessToken))).Content.Value.Organization!; + var organization2 = (await Api.PostAsync(new CreateOrganizationRequest + { + Name = "anorganizationname2" + }, req => req.SetJWTBearerToken(loginA.AccessToken))).Content.Value.Organization!; + + loginA = await ReAuthenticateUserAsync(loginA.User); + var car1Id = await CreateUnregisteredCarAsync(loginA, organization1, 2010); + var car2Id = await CreateUnregisteredCarAsync(loginA, organization2, 2010); + var car3Id = await CreateUnregisteredCarAsync(loginA, organization1, 2011); + var car4Id = await CreateUnregisteredCarAsync(loginA, organization2, 2011); + var car5Id = await CreateUnregisteredCarAsync(loginA, organization1, 2012); + var car6Id = await CreateUnregisteredCarAsync(loginA, organization2, 2012); + + var cars1 = (await Api.GetAsync(new SearchAllCarsRequest + { + OrganizationId = organization1.Id, + Sort = "+LastPersistedAtUtc" + }, req => req.SetJWTBearerToken(loginA.AccessToken))).Content.Value.Cars; + var cars2 = (await Api.GetAsync(new SearchAllCarsRequest + { + OrganizationId = organization2.Id, + Sort = "+LastPersistedAtUtc" + }, req => req.SetJWTBearerToken(loginA.AccessToken))).Content.Value.Cars; + + // Proves the Data was logically partitioned + cars1!.Count.Should().Be(3); + cars1[0].Id.Should().Be(car1Id); + cars1[1].Id.Should().Be(car3Id); + cars1[2].Id.Should().Be(car5Id); + cars2!.Count.Should().Be(3); + cars2[0].Id.Should().Be(car2Id); + cars2[1].Id.Should().Be(car4Id); + cars2[2].Id.Should().Be(car6Id); + +#if TESTINGONLY + var repository1 = + LocalMachineJsonFileStore.Create( + new FakeConfigurationSettings(StubTenantSettingsService.GetRepositoryPath(organization1.Id))); + var repository2 = + LocalMachineJsonFileStore.Create( + new FakeConfigurationSettings(StubTenantSettingsService.GetRepositoryPath(organization2.Id))); + + var carsRaw1 = (await repository1.QueryAsync(typeof(Car).GetEntityNameSafe(), Query.From() + .WhereAll() + .OrderBy(car => car.LastPersistedAtUtc), PersistedEntityMetadata.FromType(), + CancellationToken.None)) + .Value; + var carsRaw2 = (await repository2.QueryAsync(typeof(Car).GetEntityNameSafe(), + Query.From().WhereAll().OrderBy(car => car.LastPersistedAtUtc), + PersistedEntityMetadata.FromType(), CancellationToken.None)).Value; + + // Proves the Data was physically partitioned + carsRaw1.Count.Should().Be(3); + carsRaw1[0].Id.Should().Be(car1Id); + carsRaw1[1].Id.Should().Be(car3Id); + carsRaw1[2].Id.Should().Be(car5Id); + carsRaw2.Count.Should().Be(3); + carsRaw2[0].Id.Should().Be(car2Id); + carsRaw2[1].Id.Should().Be(car4Id); + carsRaw2[2].Id.Should().Be(car6Id); +#endif + } + + private static void OverrideDependencies(IServiceCollection services) + { +#if TESTINGONLY + services.RegisterUnshared(); + services + .RegisterTenanted(c => + LocalMachineJsonFileStore.Create(c.ResolveForTenant(), + c.ResolveForUnshared())); +#endif + } + + private static void DeleteAllPreviousTenants(string path) + { + var basePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var rootPath = Path.Combine(basePath, path); + if (Directory.Exists(rootPath)) + { + Directory.Delete(rootPath, true); + } + } + + private async Task CreateUnregisteredCarAsync(LoginDetails login, Organization organization, int year) + { + var car = await Api.PostAsync(new RegisterCarRequest + { + OrganizationId = organization.Id, + Make = Manufacturer.AllowedMakes[0], + Model = Manufacturer.AllowedModels[0], + Year = year, + Jurisdiction = Jurisdiction.AllowedCountries[0], + NumberPlate = "aplate" + }, req => req.SetJWTBearerToken(login.AccessToken)); + + return car.Content.Value.Car!.Id; + } + +#if TESTINGONLY + private class FakeConfigurationSettings : IConfigurationSettings + { + private readonly string _path; + + public FakeConfigurationSettings(string path) + { + _path = path; + } + + public bool IsConfigured => true; + + public bool GetBool(string key, bool? defaultValue = null) + { + throw new NotImplementedException(); + } + + public double GetNumber(string key, double? defaultValue = null) + { + throw new NotImplementedException(); + } + + public string GetString(string key, string? defaultValue = null) + { +#if TESTINGONLY + if (key == LocalMachineJsonFileStore.PathSettingName) + { + return _path; + } +#endif + + return null!; + } + + public ISettings Platform => this; + + public ISettings Tenancy => this; + } +#endif +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.IntegrationTests/Stubs/StubTenantSettingsService.cs b/src/Infrastructure.Web.Api.IntegrationTests/Stubs/StubTenantSettingsService.cs new file mode 100644 index 00000000..3b03affb --- /dev/null +++ b/src/Infrastructure.Web.Api.IntegrationTests/Stubs/StubTenantSettingsService.cs @@ -0,0 +1,31 @@ +using Application.Interfaces; +using Application.Interfaces.Services; +using Common; +using Common.Extensions; +using Infrastructure.Persistence.Common.ApplicationServices; + +namespace Infrastructure.Web.Api.IntegrationTests.Stubs; + +public class StubTenantSettingsService : ITenantSettingsService +{ + public async Task> CreateForTenantAsync(ICallerContext context, string tenantId, + CancellationToken cancellationToken) + { + await Task.CompletedTask; + return new TenantSettings(new Dictionary + { +#if TESTINGONLY + { + LocalMachineJsonFileStore.PathSettingName, + new TenantSetting { Value = GetRepositoryPath(tenantId) } + } +#endif + }); + } + + public static string GetRepositoryPath(string? tenantId) + { + //Copy this value from the appsettings.Testing.json file + return $"./saastack/testing/apis/tenants/{tenantId}".WithoutTrailingSlash(); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Interfaces/ITenantDetective.cs b/src/Infrastructure.Web.Api.Interfaces/ITenantDetective.cs new file mode 100644 index 00000000..17e63e88 --- /dev/null +++ b/src/Infrastructure.Web.Api.Interfaces/ITenantDetective.cs @@ -0,0 +1,33 @@ +using Common; +using Microsoft.AspNetCore.Http; + +namespace Infrastructure.Web.Api.Interfaces; + +/// +/// Defines a detective that can be used to determine the tenant for a request +/// +public interface ITenantDetective +{ + /// + /// Returns the ID of the tenant for the specified request in the current + /// that could be of type ,and also whether a tenant should exist for the specific request + /// + Task> DetectTenantAsync(HttpContext httpContext, + Optional requestDtoType, CancellationToken cancellationToken); +} + +/// +/// Defines the result of a tenant detection +/// +public class TenantDetectionResult +{ + public TenantDetectionResult(bool shouldHaveTenantId, Optional tenantId) + { + ShouldHaveTenantId = shouldHaveTenantId; + TenantId = tenantId; + } + + public bool ShouldHaveTenantId { get; } + + public Optional TenantId { get; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Interfaces/ITenantedRequest.cs b/src/Infrastructure.Web.Api.Interfaces/ITenantedRequest.cs index bad494fa..7cbc1d07 100644 --- a/src/Infrastructure.Web.Api.Interfaces/ITenantedRequest.cs +++ b/src/Infrastructure.Web.Api.Interfaces/ITenantedRequest.cs @@ -4,40 +4,6 @@ namespace Infrastructure.Web.Api.Interfaces; /// Defines the request for a specific Tenant /// public interface ITenantedRequest -{ - public string? OrganizationId { get; set; } -} - -/// -/// Defines the request of a POST/GET/PUT/PATCH API for an Organization, with an empty response -/// -public class TenantedEmptyRequest : IWebRequest, ITenantedRequest -{ - public string? OrganizationId { get; set; } -} - -/// -/// Defines the request of a POST/GET/PUT/PATCH API for an Organization -/// -public class TenantedRequest : IWebRequest, ITenantedRequest - where TResponse : IWebResponse -{ - public string? OrganizationId { get; set; } -} - -/// -/// Defines the request of a SEARCH API for an Organization -/// -public class TenantedSearchRequest : SearchRequest, ITenantedRequest - where TResponse : IWebSearchResponse -{ - public string? OrganizationId { get; set; } -} - -/// -/// Defines the request of a DELETE API for an Organization -/// -public class TenantedDeleteRequest : IWebRequestVoid, ITenantedRequest { public string? OrganizationId { get; set; } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Interfaces/TenantedRequests.cs b/src/Infrastructure.Web.Api.Interfaces/TenantedRequests.cs new file mode 100644 index 00000000..08a15f23 --- /dev/null +++ b/src/Infrastructure.Web.Api.Interfaces/TenantedRequests.cs @@ -0,0 +1,35 @@ +namespace Infrastructure.Web.Api.Interfaces; + +/// +/// Defines the request of a POST/GET/PUT/PATCH API for an Organization, with an empty response +/// +public class TenantedEmptyRequest : IWebRequest, ITenantedRequest +{ + public string? OrganizationId { get; set; } +} + +/// +/// Defines the request of a POST/GET/PUT/PATCH API for an Organization +/// +public class TenantedRequest : IWebRequest, ITenantedRequest + where TResponse : IWebResponse +{ + public string? OrganizationId { get; set; } +} + +/// +/// Defines the request of a SEARCH API for an Organization +/// +public class TenantedSearchRequest : SearchRequest, ITenantedRequest + where TResponse : IWebSearchResponse +{ + public string? OrganizationId { get; set; } +} + +/// +/// Defines the request of a DELETE API for an Organization +/// +public class TenantedDeleteRequest : IWebRequestVoid, ITenantedRequest +{ + public string? OrganizationId { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/DeliverProvisioningRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/DeliverProvisioningRequest.cs new file mode 100644 index 00000000..94754670 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/DeliverProvisioningRequest.cs @@ -0,0 +1,10 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Ancillary; + +[Route("/provisioning/deliver", ServiceOperation.Post, AccessType.HMAC)] +[Authorize(Roles.Platform_ServiceAccount)] +public class DeliverProvisioningRequest : UnTenantedRequest +{ + public required string Message { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/DrainAllProvisioningsRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/DrainAllProvisioningsRequest.cs new file mode 100644 index 00000000..d3114493 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/DrainAllProvisioningsRequest.cs @@ -0,0 +1,11 @@ +#if TESTINGONLY +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Ancillary; + +[Route("/provisioning/drain", ServiceOperation.Post, AccessType.HMAC, true)] +[Authorize(Roles.Platform_ServiceAccount)] +public class DrainAllProvisioningsRequest : UnTenantedEmptyRequest +{ +} +#endif \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Organizations/CreateOrganizationRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/CreateOrganizationRequest.cs new file mode 100644 index 00000000..6451ec22 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/CreateOrganizationRequest.cs @@ -0,0 +1,10 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Organizations; + +[Route("/organizations", ServiceOperation.Post, AccessType.Token)] +[Authorize(Roles.Platform_Standard, Features.Platform_PaidTrial)] +public class CreateOrganizationRequest : UnTenantedRequest +{ + public required string Name { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Organizations/GetOrganizationRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/GetOrganizationRequest.cs new file mode 100644 index 00000000..50119511 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/GetOrganizationRequest.cs @@ -0,0 +1,10 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Organizations; + +[Route("/organizations/{Id}", ServiceOperation.Get, AccessType.Token)] +[Authorize(Roles.Platform_Standard)] +public class GetOrganizationRequest : UnTenantedRequest +{ + public required string Id { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Organizations/GetOrganizationResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/GetOrganizationResponse.cs new file mode 100644 index 00000000..780dca9e --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/GetOrganizationResponse.cs @@ -0,0 +1,9 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Organizations; + +public class GetOrganizationResponse : IWebResponse +{ + public Organization? Organization { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Organizations/GetOrganizationSettingsRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/GetOrganizationSettingsRequest.cs new file mode 100644 index 00000000..c4366b8a --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/GetOrganizationSettingsRequest.cs @@ -0,0 +1,12 @@ +#if TESTINGONLY +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Organizations; + +[Route("/organizations/{Id}/settings", ServiceOperation.Get, AccessType.Token, true)] +[Authorize(Roles.Platform_Standard)] +public class GetOrganizationSettingsRequest : UnTenantedRequest +{ + public required string Id { get; set; } +} +#endif \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Organizations/GetOrganizationSettingsResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/GetOrganizationSettingsResponse.cs new file mode 100644 index 00000000..e180136e --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Organizations/GetOrganizationSettingsResponse.cs @@ -0,0 +1,11 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Organizations; + +public class GetOrganizationSettingsResponse : IWebResponse +{ + public Organization? Organization { get; set; } + + public Dictionary? Settings { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Common/Clients/JsonClient.cs b/src/Infrastructure.Web.Common/Clients/JsonClient.cs index 61727858..83d48d7c 100644 --- a/src/Infrastructure.Web.Common/Clients/JsonClient.cs +++ b/src/Infrastructure.Web.Common/Clients/JsonClient.cs @@ -257,7 +257,7 @@ private async Task SendRequestAsync(HttpMethod method, IWeb { var requestUri = request.GetRequestInfo().Route; - var content = method == HttpMethod.Post || method == HttpMethod.Put || method == HttpMethod.Patch + var content = method.CanHaveBody() ? new StringContent(request.SerializeToJson(), new MediaTypeHeaderValue(HttpContentTypes.Json)) : null; diff --git a/src/Infrastructure.Web.Hosting.Common.UnitTests/AnonymousCallerContextSpec.cs b/src/Infrastructure.Web.Hosting.Common.UnitTests/AnonymousCallerContextSpec.cs index 6b89f290..0ff3fafe 100644 --- a/src/Infrastructure.Web.Hosting.Common.UnitTests/AnonymousCallerContextSpec.cs +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/AnonymousCallerContextSpec.cs @@ -1,5 +1,5 @@ using FluentAssertions; -using Infrastructure.Web.Api.Common; +using Infrastructure.Web.Api.Common.Endpoints; using Microsoft.AspNetCore.Http; using Moq; using Xunit; diff --git a/src/Infrastructure.Web.Hosting.Common.UnitTests/ApplicationServices/AspNetHostLocalFileTenantSettingsServiceSpec.cs b/src/Infrastructure.Web.Hosting.Common.UnitTests/ApplicationServices/AspNetHostLocalFileTenantSettingsServiceSpec.cs index 41940be0..06809a56 100644 --- a/src/Infrastructure.Web.Hosting.Common.UnitTests/ApplicationServices/AspNetHostLocalFileTenantSettingsServiceSpec.cs +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/ApplicationServices/AspNetHostLocalFileTenantSettingsServiceSpec.cs @@ -1,5 +1,5 @@ using Application.Common; -using Application.Interfaces.Resources; +using Application.Interfaces.Services; using FluentAssertions; using Infrastructure.Web.Hosting.Common.ApplicationServices; using JetBrains.Annotations; @@ -35,13 +35,14 @@ public GivenNoEncryptedSettings() } [Fact] - public void WhenCreateForNewTenant_ThenReturnsSettings() + public async Task WhenCreateForNewTenant_ThenReturnsSettings() { var result = - _service.CreateForNewTenant(Caller.CreateAsAnonymousTenant("atenantid"), "atenantid"); + await _service.CreateForTenantAsync(Caller.CreateAsAnonymousTenant("atenantid"), "atenantid", + CancellationToken.None); - result.Count.Should().Be(1); - result["AGroup:ASubGroup:ASetting"].Should() + result.Value.Count.Should().Be(1); + result.Value["AGroup:ASubGroup:ASetting"].Should() .BeEquivalentTo(new TenantSetting { Value = "avalue", IsEncrypted = false }); } } @@ -61,17 +62,18 @@ public GivenEncryptedSettings() } [Fact] - public void WhenCreateForNewTenant_ThenReturnsSettings() + public async Task WhenCreateForNewTenant_ThenReturnsSettings() { var result = - _service.CreateForNewTenant(Caller.CreateAsAnonymousTenant("atenantid"), "atenantid"); + await _service.CreateForTenantAsync(Caller.CreateAsAnonymousTenant("atenantid"), "atenantid", + CancellationToken.None); - result.Count.Should().Be(3); - result["AGroup:ASubGroup:ASetting1"].Should() + result.Value.Count.Should().Be(3); + result.Value["AGroup:ASubGroup:ASetting1"].Should() .BeEquivalentTo(new TenantSetting { Value = "avalue1", IsEncrypted = true }); - result["AGroup:ASubGroup:ASetting2"].Should() + result.Value["AGroup:ASubGroup:ASetting2"].Should() .BeEquivalentTo(new TenantSetting { Value = "avalue2", IsEncrypted = false }); - result["AGroup:ASubGroup:ASetting3"].Should() + result.Value["AGroup:ASubGroup:ASetting3"].Should() .BeEquivalentTo(new TenantSetting { Value = "avalue3", IsEncrypted = true }); } } diff --git a/src/Infrastructure.Web.Hosting.Common.UnitTests/AspNetCallerContextSpec.cs b/src/Infrastructure.Web.Hosting.Common.UnitTests/AspNetCallerContextSpec.cs index fec840be..06413d7c 100644 --- a/src/Infrastructure.Web.Hosting.Common.UnitTests/AspNetCallerContextSpec.cs +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/AspNetCallerContextSpec.cs @@ -6,6 +6,7 @@ using Infrastructure.Common.Extensions; using Infrastructure.Interfaces; using Infrastructure.Web.Api.Common; +using Infrastructure.Web.Api.Common.Endpoints; using Infrastructure.Web.Hosting.Common.Auth; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -22,14 +23,53 @@ namespace Infrastructure.Web.Hosting.Common.UnitTests; public class AspNetCallerContextSpec { private readonly Mock _httpContext; + private readonly Mock _serviceProvider; + private readonly Mock _tenancyContext; public AspNetCallerContextSpec() { + _tenancyContext = new Mock(); + _tenancyContext.Setup(tc => tc.Current) + .Returns("atenantid"); + _serviceProvider = new Mock(); + _serviceProvider.Setup(sp => sp.GetService(typeof(ITenancyContext))) + .Returns(_tenancyContext.Object); _httpContext = new Mock(); _httpContext.Setup(hc => hc.HttpContext!.Items).Returns(new Dictionary()); _httpContext.Setup(hc => hc.HttpContext!.User.Claims).Returns(new List()); _httpContext.Setup(hc => hc.HttpContext!.Request.Headers).Returns(new HeaderDictionary()); _httpContext.Setup(hc => hc.HttpContext!.Features).Returns(new FeatureCollection()); + _httpContext.Setup(hc => hc.HttpContext!.RequestServices).Returns(_serviceProvider.Object); + } + + [Fact] + public void WhenConstructedAndNoTenancyContext_ThenNoTenant() + { + _serviceProvider.Setup(sp => sp.GetService(typeof(ITenancyContext))) + .Returns((ITenancyContext?)null); + + var result = new AspNetCallerContext(_httpContext.Object); + + result.TenantId.Should().BeNull(); + } + + [Fact] + public void WhenConstructedAndNoTenantInTenancyContext_ThenNoTenant() + { + _tenancyContext.Setup(tc => tc.Current) + .Returns((string?)null); + + var result = new AspNetCallerContext(_httpContext.Object); + + result.TenantId.Should().BeNull(); + } + + [Fact] + public void WhenConstructed_ThenTenantId() + { + var result = new AspNetCallerContext(_httpContext.Object); + + result.TenantId.Should().Be("atenantid"); } [Fact] @@ -114,7 +154,7 @@ public void WhenConstructedAndContainsRolesClaims_ThenSetsRoles() ClaimExtensions.ToPlatformClaimValue(PlatformRoles.Standard)), new(AuthenticationConstants.Claims.ForRole, ClaimExtensions.ToTenantClaimValue(TenantRoles.Member, - MultiTenancyConstants.DefaultOrganizationId)) + "atenantid")) }); var result = new AspNetCallerContext(_httpContext.Object); @@ -148,7 +188,7 @@ public void WhenConstructedAndContainsFeaturesClaims_ThenSetsFeatures() ClaimExtensions.ToPlatformClaimValue(PlatformFeatures.Basic)), new(AuthenticationConstants.Claims.ForFeature, ClaimExtensions.ToTenantClaimValue(TenantFeatures.Basic, - MultiTenancyConstants.DefaultOrganizationId)) + "atenantid")) }); var result = new AspNetCallerContext(_httpContext.Object); diff --git a/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/CSRFMiddlewareSpec.cs b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/CSRFMiddlewareSpec.cs index 43e4eec6..794d5f1c 100644 --- a/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/CSRFMiddlewareSpec.cs +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/CSRFMiddlewareSpec.cs @@ -61,7 +61,7 @@ public async Task WhenInvokeAsyncAndIsIgnoredMethod_ThenContinuesPipeline() } [Fact] - public async Task WhenInvokeAsyncAndMissingHostName_ThenReturnsError() + public async Task WhenInvokeAsyncAndMissingHostName_ThenRespondsWithAProblem() { var context = SetupContext(); _hostSettings.Setup(s => s.GetWebsiteHostBaseUrl()).Returns("notauri"); @@ -74,7 +74,7 @@ public async Task WhenInvokeAsyncAndMissingHostName_ThenReturnsError() } [Fact] - public async Task WhenInvokeAsyncAndMissingCookie_ThenReturnsError() + public async Task WhenInvokeAsyncAndMissingCookie_ThenRespondsWithAProblem() { var context = SetupContext(); @@ -86,7 +86,7 @@ public async Task WhenInvokeAsyncAndMissingCookie_ThenReturnsError() } [Fact] - public async Task WhenInvokeAsyncAndMissingHeader_ThenReturnsError() + public async Task WhenInvokeAsyncAndMissingHeader_ThenRespondsWithAProblem() { var context = SetupContext(); context.Request.Cookies = SetupCookies(new Dictionary @@ -100,7 +100,7 @@ public async Task WhenInvokeAsyncAndMissingHeader_ThenReturnsError() } [Fact] - public async Task WhenInvokeAsyncAndAuthTokenIsInvalid_ThenReturnsError() + public async Task WhenInvokeAsyncAndAuthTokenIsInvalid_ThenRespondsWithAProblem() { var context = SetupContext(); context.Request.Cookies = SetupCookies(new Dictionary @@ -118,7 +118,7 @@ public async Task WhenInvokeAsyncAndAuthTokenIsInvalid_ThenReturnsError() } [Fact] - public async Task WhenInvokeAsyncAndTokenNotContainUserIdClaim_ThenReturnsError() + public async Task WhenInvokeAsyncAndTokenNotContainUserIdClaim_ThenRespondsWithAProblem() { var tokenWithoutUserClaim = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( claims: new Claim[] { } @@ -139,7 +139,7 @@ public async Task WhenInvokeAsyncAndTokenNotContainUserIdClaim_ThenReturnsError( } [Fact] - public async Task WhenInvokeAsyncAndTokensNotVerifiedForNoUser_ThenReturnsError() + public async Task WhenInvokeAsyncAndTokensNotVerifiedForNoUser_ThenRespondsWithAProblem() { var context = SetupContext(); context.Request.Cookies = SetupCookies(new Dictionary @@ -162,7 +162,7 @@ public async Task WhenInvokeAsyncAndTokensNotVerifiedForNoUser_ThenReturnsError( } [Fact] - public async Task WhenInvokeAsyncAndTokensNotVerifiedForUser_ThenReturnsError() + public async Task WhenInvokeAsyncAndTokensNotVerifiedForUser_ThenRespondsWithAProblem() { var tokenForUser = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( claims: new Claim[] @@ -191,7 +191,7 @@ public async Task WhenInvokeAsyncAndTokensNotVerifiedForUser_ThenReturnsError() } [Fact] - public async Task WhenInvokeAsyncAndTokensIsVerifiedButNoOriginAndNoReferer_ThenReturnsError() + public async Task WhenInvokeAsyncAndTokensIsVerifiedButNoOriginAndNoReferer_ThenRespondsWithAProblem() { var tokenForUser = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( claims: new Claim[] @@ -222,7 +222,7 @@ public async Task WhenInvokeAsyncAndTokensIsVerifiedButNoOriginAndNoReferer_Then } [Fact] - public async Task WhenInvokeAsyncAndTokensIsVerifiedButOriginNotHost_ThenReturnsError() + public async Task WhenInvokeAsyncAndTokensIsVerifiedButOriginNotHost_ThenRespondsWithAProblem() { var tokenForUser = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( claims: new Claim[] @@ -253,7 +253,7 @@ public async Task WhenInvokeAsyncAndTokensIsVerifiedButOriginNotHost_ThenReturns } [Fact] - public async Task WhenInvokeAsyncAndTokensIsVerifiedButRefererNotHost_ThenReturnsError() + public async Task WhenInvokeAsyncAndTokensIsVerifiedButRefererNotHost_ThenRespondsWithAProblem() { var tokenForUser = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( claims: new Claim[] diff --git a/src/Infrastructure.Web.Hosting.Common.UnitTests/MiddlewareTestingAssertions.cs b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/MiddlewareTestingAssertions.cs similarity index 97% rename from src/Infrastructure.Web.Hosting.Common.UnitTests/MiddlewareTestingAssertions.cs rename to src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/MiddlewareTestingAssertions.cs index 42ef35a0..3d54adaa 100644 --- a/src/Infrastructure.Web.Hosting.Common.UnitTests/MiddlewareTestingAssertions.cs +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/MiddlewareTestingAssertions.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Infrastructure.Web.Hosting.Common.UnitTests; +namespace Infrastructure.Web.Hosting.Common.UnitTests.Pipeline; internal static class ResultExtensions { diff --git a/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/MultiTenancyMiddlewareSpec.cs b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/MultiTenancyMiddlewareSpec.cs new file mode 100644 index 00000000..03cda988 --- /dev/null +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/MultiTenancyMiddlewareSpec.cs @@ -0,0 +1,631 @@ +using System.Net; +using System.Reflection; +using Application.Interfaces; +using Application.Interfaces.Services; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Common.Extensions; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Hosting.Common.Pipeline; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Infrastructure.Web.Hosting.Common.UnitTests.Pipeline; + +[UsedImplicitly] +public class MultiTenancyMiddlewareSpec +{ + private static HttpContext SetupContext(ICallerContextFactory callerContextFactory, + ITenancyContext tenancyContext) + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(new LoggerFactory()); + serviceCollection.AddSingleton(callerContextFactory); + serviceCollection.AddSingleton(tenancyContext); + var serviceProvider = serviceCollection.BuildServiceProvider(); + + var context = new DefaultHttpContext + { + RequestServices = serviceProvider, + Response = + { + StatusCode = 200, + Body = new MemoryStream() + } + }; + + return context; + } + + [Trait("Category", "Unit")] + public class GivenAnyCaller + { + private readonly Mock _callerContextFactory; + private readonly Mock _endUsersService; + private readonly MultiTenancyMiddleware _middleware; + private readonly Mock _next; + private readonly Mock _tenancyContext; + private readonly Mock _tenantDetective; + + public GivenAnyCaller() + { + var identifierFactory = new Mock(); + identifierFactory.Setup(idf => idf.IsValid(It.IsAny())) + .Returns(true); + _tenancyContext = new Mock(); + _callerContextFactory = new Mock(); + var caller = new Mock(); + caller.Setup(c => c.IsAuthenticated) + .Returns(false); + _callerContextFactory.Setup(ccf => ccf.Create()) + .Returns(caller.Object); + var organizationsService = new Mock(); + organizationsService.Setup(os => + os.GetSettingsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new TenantSettings()); + _endUsersService = new Mock(); + _tenantDetective = new Mock(); + _tenantDetective.Setup(td => + td.DetectTenantAsync(It.IsAny(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TenantDetectionResult(false, null)); + _next = new Mock(); + + _middleware = new MultiTenancyMiddleware(_next.Object, identifierFactory.Object, _endUsersService.Object, + organizationsService.Object); + } + + [Fact] + public async Task WhenInvokeAndHasNoEndpoint_ThenContinuesPipeline() + { + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + _next.Verify(n => n.Invoke(context)); + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); + _tenancyContext.Verify(t => t.Set(It.IsAny(), It.IsAny()), Times.Never); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WhenInvokeAndHasEndpointWithNoParameters_ThenContinuesPipeline() + { + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + context.SetEndpoint(new Endpoint(_ => Task.CompletedTask, EndpointMetadataCollection.Empty, "aroute")); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + _next.Verify(n => n.Invoke(context)); + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); + _tenancyContext.Verify(t => t.Set(It.IsAny(), It.IsAny()), Times.Never); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WhenInvokeAndHasEndpointWithWrongNumberOfParameters_ThenContinuesPipeline() + { + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + var methodDelegate = () => { }; + var metadata = new EndpointMetadataCollection(methodDelegate.GetMethodInfo()); + context.SetEndpoint(new Endpoint(_ => Task.CompletedTask, metadata, "aroute")); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + _next.Verify(n => n.Invoke(context)); + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); + _tenancyContext.Verify(t => t.Set(It.IsAny(), It.IsAny()), Times.Never); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WhenInvokeAndHasEndpointWithWrongRequestType_ThenContinuesPipeline() + { + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + var methodDelegate = (object _, TestIllegalRequest request) => { }; + var metadata = new EndpointMetadataCollection(methodDelegate.GetMethodInfo()); + context.SetEndpoint(new Endpoint(_ => Task.CompletedTask, metadata, "aroute")); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + _next.Verify(n => n.Invoke(context)); + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); + _tenancyContext.Verify(t => t.Set(It.IsAny(), It.IsAny()), Times.Never); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WhenInvokeAndHasEndpointWithCorrectRequestType_ThenContinuesPipeline() + { + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + var methodDelegate = (object _, TestTenantedRequest request) => { }; + var metadata = new EndpointMetadataCollection(methodDelegate.GetMethodInfo()); + context.SetEndpoint(new Endpoint(_ => Task.CompletedTask, metadata, "aroute")); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + _next.Verify(n => n.Invoke(context)); + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, typeof(TestTenantedRequest), CancellationToken.None)); + _tenancyContext.Verify(t => t.Set(It.IsAny(), It.IsAny()), Times.Never); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny()), + Times.Never); + } + } + + [Trait("Category", "Unit")] + public class GivenAnAnonymousUser + { + private readonly Mock _callerContextFactory; + private readonly Mock _endUsersService; + private readonly Mock _identifierFactory; + private readonly MultiTenancyMiddleware _middleware; + private readonly Mock _next; + private readonly Mock _organizationsService; + private readonly Mock _tenancyContext; + private readonly Mock _tenantDetective; + + public GivenAnAnonymousUser() + { + _identifierFactory = new Mock(); + _identifierFactory.Setup(idf => idf.IsValid(It.IsAny())) + .Returns(true); + _tenancyContext = new Mock(); + _callerContextFactory = new Mock(); + var caller = new Mock(); + caller.Setup(c => c.IsAuthenticated) + .Returns(false); + _callerContextFactory.Setup(ccf => ccf.Create()) + .Returns(caller.Object); + _organizationsService = new Mock(); + _organizationsService.Setup(os => + os.GetSettingsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new TenantSettings()); + _endUsersService = new Mock(); + _tenantDetective = new Mock(); + _tenantDetective.Setup(td => + td.DetectTenantAsync(It.IsAny(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TenantDetectionResult(false, null)); + _next = new Mock(); + + _middleware = new MultiTenancyMiddleware(_next.Object, _identifierFactory.Object, _endUsersService.Object, + _organizationsService.Object); + } + + [Fact] + public async Task WhenInvokeAndNoUnRequiredTenantId_ThenContinuesPipeline() + { + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + _next.Verify(n => n.Invoke(context)); + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); + _tenancyContext.Verify(t => t.Set(It.IsAny(), It.IsAny()), Times.Never); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WhenInvokeAndNoRequiredTenantId_ThenRespondsWithAProblem() + { + _tenantDetective.Setup(td => + td.DetectTenantAsync(It.IsAny(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TenantDetectionResult(true, null)); + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + context.Response.Should().BeAProblem(HttpStatusCode.BadRequest, + Resources.MultiTenancyMiddleware_MissingTenantId); + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); + _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); + _tenancyContext.Verify(t => t.Set(It.IsAny(), It.IsAny()), Times.Never); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WhenInvokeAndTenantedRequestButTenantIdIsInvalid_ThenRespondsWithAProblem() + { + _identifierFactory.Setup(idf => idf.IsValid(It.IsAny())) + .Returns(false); + _tenantDetective.Setup(td => + td.DetectTenantAsync(It.IsAny(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TenantDetectionResult(true, "atenantid")); + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + context.Response.Should().BeAProblem(HttpStatusCode.BadRequest, + Resources.MultiTenancyMiddleware_InvalidTenantId); + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); + _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); + _tenancyContext.Verify(t => t.Set(It.IsAny(), It.IsAny()), Times.Never); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WhenInvokeWithUnRequiredTenantId_ThenSetsTenantAndContinuesPipeline() + { + _tenantDetective.Setup(td => + td.DetectTenantAsync(It.IsAny(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TenantDetectionResult(false, "atenantid")); + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); + _next.Verify(n => n.Invoke(It.IsAny())); + _tenancyContext.Verify(t => t.Set("atenantid", It.IsAny())); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny()), + Times.Never); + _organizationsService.Verify(os => + os.GetSettingsPrivateAsync(It.IsAny(), "atenantid", It.IsAny())); + } + + [Fact] + public async Task WhenInvokeWithRequiredTenantId_ThenSetsTenantAndContinuesPipeline() + { + _tenantDetective.Setup(td => + td.DetectTenantAsync(It.IsAny(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TenantDetectionResult(true, "atenantid")); + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); + _next.Verify(n => n.Invoke(It.IsAny())); + _tenancyContext.Verify(t => t.Set("atenantid", It.IsAny())); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny()), + Times.Never); + _organizationsService.Verify(os => + os.GetSettingsPrivateAsync(It.IsAny(), "atenantid", It.IsAny())); + } + } + + [Trait("Category", "Unit")] + public class GivenAnAuthenticatedUser + { + private readonly Mock _caller; + private readonly Mock _callerContextFactory; + private readonly Mock _endUsersService; + private readonly MultiTenancyMiddleware _middleware; + private readonly Mock _next; + private readonly Mock _organizationsService; + private readonly Mock _tenancyContext; + private readonly Mock _tenantDetective; + + public GivenAnAuthenticatedUser() + { + var identifierFactory = new Mock(); + identifierFactory.Setup(idf => idf.IsValid(It.IsAny())) + .Returns(true); + _tenancyContext = new Mock(); + _callerContextFactory = new Mock(); + _caller = new Mock(); + _caller.Setup(cc => cc.IsAuthenticated) + .Returns(true); + _caller.Setup(cc => cc.CallerId) + .Returns("acallerid"); + _callerContextFactory.Setup(ccf => ccf.Create()) + .Returns(_caller.Object); + _organizationsService = new Mock(); + _organizationsService.Setup(os => + os.GetSettingsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new TenantSettings()); + _endUsersService = new Mock(); + _endUsersService.Setup(os => + os.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EndUserWithMemberships + { + Id = "auserid", + Memberships = new List() + }); + _tenantDetective = new Mock(); + _tenantDetective.Setup(td => + td.DetectTenantAsync(It.IsAny(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TenantDetectionResult(false, null)); + + _next = new Mock(); + + _middleware = new MultiTenancyMiddleware(_next.Object, identifierFactory.Object, _endUsersService.Object, + _organizationsService.Object); + } + + [Fact] + public async Task WhenInvokeAndUnRequiredTenantIdButNotAMember_ThenRespondsWithAProblem() + { + _tenantDetective.Setup(td => + td.DetectTenantAsync(It.IsAny(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TenantDetectionResult(false, "atenantid")); + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + context.Response.Should().BeAProblem(HttpStatusCode.Forbidden, + Resources.MultiTenancyMiddleware_UserNotAMember.Format("atenantid")); + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); + _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); + _tenancyContext.Verify(t => t.Set(It.IsAny(), It.IsAny()), Times.Never); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(It.IsAny(), "acallerid", It.IsAny())); + } + + [Fact] + public async Task WhenInvokeAndUnRequiredTenantIdAndIsAMember_ThenSetsTenantAndContinuesPipeline() + { + _tenantDetective.Setup(td => + td.DetectTenantAsync(It.IsAny(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TenantDetectionResult(false, "atenantid")); + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + _endUsersService.Setup(os => + os.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EndUserWithMemberships + { + Id = "auserid", + Memberships = new List + { + new() + { + Id = "amembershipid", + OrganizationId = "atenantid" + } + } + }); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); + _next.Verify(n => n.Invoke(It.IsAny())); + _tenancyContext.Verify(t => t.Set("atenantid", It.IsAny())); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(It.IsAny(), "acallerid", It.IsAny())); + _organizationsService.Verify(os => + os.GetSettingsPrivateAsync(It.IsAny(), "atenantid", It.IsAny())); + } + + [Fact] + public async Task WhenInvokeAndNoRequiredTenantIdButNoMemberships_ThenRespondsWithAProblem() + { + _tenantDetective.Setup(td => + td.DetectTenantAsync(It.IsAny(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TenantDetectionResult(true, null)); + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + context.Response.Should().BeAProblem(HttpStatusCode.BadRequest, + Resources.MultiTenancyMiddleware_MissingTenantId); + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); + _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); + _tenancyContext.Verify(t => t.Set(It.IsAny(), It.IsAny()), Times.Never); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(It.IsAny(), "acallerid", It.IsAny())); + } + + [Fact] + public async Task WhenInvokeAndNoRequiredTenantIdButNoDefaultOrganization_ThenRespondsWithAProblem() + { + _tenantDetective.Setup(td => + td.DetectTenantAsync(It.IsAny(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TenantDetectionResult(true, null)); + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + _endUsersService.Setup(os => + os.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EndUserWithMemberships + { + Id = "auserid", + Memberships = new List + { + new() + { + Id = "amembershipid", + OrganizationId = "atenantid" + } + } + }); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + context.Response.Should().BeAProblem(HttpStatusCode.BadRequest, + Resources.MultiTenancyMiddleware_MissingTenantId); + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); + _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); + _tenancyContext.Verify(t => t.Set(It.IsAny(), It.IsAny()), Times.Never); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(It.IsAny(), "acallerid", It.IsAny())); + } + + [Fact] + public async Task WhenInvokeAndNoRequiredTenantIdButHasDefaultOrganization_ThenSetsTenantAndContinuesPipeline() + { + _tenantDetective.Setup(td => + td.DetectTenantAsync(It.IsAny(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TenantDetectionResult(true, null)); + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + _endUsersService.Setup(os => + os.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EndUserWithMemberships + { + Id = "auserid", + Memberships = new List + { + new() + { + Id = "amembershipid", + IsDefault = true, + OrganizationId = "adefaultorganizationid" + } + } + }); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + _next.Verify(n => n.Invoke(It.IsAny())); + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); + _tenancyContext.Verify(t => t.Set("adefaultorganizationid", It.IsAny())); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(_caller.Object, "acallerid", CancellationToken.None)); + _organizationsService.Verify(os => + os.GetSettingsPrivateAsync(It.IsAny(), "adefaultorganizationid", + It.IsAny())); + } + + [Fact] + public async Task WhenInvokeAndRequiredTenantIdButNotAMember_ThenRespondsWithAProblem() + { + _tenantDetective.Setup(td => + td.DetectTenantAsync(It.IsAny(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TenantDetectionResult(true, "atenantid")); + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + context.Response.Should().BeAProblem(HttpStatusCode.Forbidden, + Resources.MultiTenancyMiddleware_UserNotAMember.Format("atenantid")); + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); + _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); + _tenancyContext.Verify(t => t.Set(It.IsAny(), It.IsAny()), Times.Never); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(It.IsAny(), "acallerid", It.IsAny())); + } + + [Fact] + public async Task WhenInvokeAndRequiredTenantIdAndIsAMember_ThenSetsTenantAndContinuesPipeline() + { + _tenantDetective.Setup(td => + td.DetectTenantAsync(It.IsAny(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TenantDetectionResult(true, "atenantid")); + var context = SetupContext(_callerContextFactory.Object, _tenancyContext.Object); + _endUsersService.Setup(os => + os.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EndUserWithMemberships + { + Id = "auserid", + Memberships = new List + { + new() + { + Id = "amembershipid", + OrganizationId = "atenantid" + } + } + }); + + await _middleware.InvokeAsync(context, _tenancyContext.Object, _callerContextFactory.Object, + _tenantDetective.Object); + + _next.Verify(n => n.Invoke(It.IsAny())); + _tenantDetective.Verify(td => + td.DetectTenantAsync(context, Optional.None, CancellationToken.None)); + _tenancyContext.Verify(t => t.Set("atenantid", It.IsAny())); + _organizationsService.Verify(os => + os.GetSettingsPrivateAsync(It.IsAny(), "atenantid", It.IsAny())); + _endUsersService.Verify(os => + os.GetMembershipsPrivateAsync(It.IsAny(), "acallerid", + It.IsAny())); + } + } + + [UsedImplicitly] + private class TestIllegalRequest + { + } + + [UsedImplicitly] + private class TestTenantedRequest : IWebRequest, ITenantedRequest + { + public string? OrganizationId { get; set; } + } + + [UsedImplicitly] + private class TestResponse : IWebResponse + { + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common/AnonymousCallerContext.cs b/src/Infrastructure.Web.Hosting.Common/AnonymousCallerContext.cs index c6695f07..5c6098d1 100644 --- a/src/Infrastructure.Web.Hosting.Common/AnonymousCallerContext.cs +++ b/src/Infrastructure.Web.Hosting.Common/AnonymousCallerContext.cs @@ -3,7 +3,7 @@ using Common; using Domain.Interfaces; using Domain.Interfaces.Authorization; -using Infrastructure.Web.Api.Common; +using Infrastructure.Web.Api.Common.Endpoints; using Microsoft.AspNetCore.Http; namespace Infrastructure.Web.Hosting.Common; diff --git a/src/Infrastructure.Web.Hosting.Common/ApplicationServices/AspNetHostLocalFileTenantSettingsService.cs b/src/Infrastructure.Web.Hosting.Common/ApplicationServices/AspNetHostLocalFileTenantSettingsService.cs index 6a2b52aa..c89a74f2 100644 --- a/src/Infrastructure.Web.Hosting.Common/ApplicationServices/AspNetHostLocalFileTenantSettingsService.cs +++ b/src/Infrastructure.Web.Hosting.Common/ApplicationServices/AspNetHostLocalFileTenantSettingsService.cs @@ -1,6 +1,6 @@ using Application.Interfaces; -using Application.Interfaces.Resources; using Application.Interfaces.Services; +using Common; using Common.Extensions; using Microsoft.Extensions.Configuration; @@ -16,7 +16,8 @@ public class AspNetHostLocalFileTenantSettingsService : ITenantSettingsService { private const string HostProjectFileName = "tenantsettings.json"; private const string SettingsEncryptedAtRestSettingName = "SettingsEncryptedAtRest"; - private static Dictionary? _cachedSettings; + private static TenantSettings? _cachedSettings; + private static readonly char[] EntryMultipleValueDelimiters = { ' ', ',', ';' }; private readonly string _filename; public AspNetHostLocalFileTenantSettingsService() : this(HostProjectFileName) @@ -28,8 +29,10 @@ internal AspNetHostLocalFileTenantSettingsService(string filename) _filename = filename; } - public IReadOnlyDictionary CreateForNewTenant(ICallerContext context, string tenantId) + public async Task> CreateForTenantAsync(ICallerContext context, string tenantId, + CancellationToken cancellationToken) { + await Task.CompletedTask; if (_cachedSettings.NotExists()) { var configuration = new ConfigurationBuilder() @@ -43,14 +46,14 @@ public IReadOnlyDictionary CreateForNewTenant(ICallerCont var encryptedKeys = entriesWithValues .GetValueOrDefault(SettingsEncryptedAtRestSettingName, string.Empty)! - .Split(' ', ',', ';') + .Split(EntryMultipleValueDelimiters) .Where(key => key.HasValue()); entriesWithValues.Remove(SettingsEncryptedAtRestSettingName); - _cachedSettings = entriesWithValues + _cachedSettings = new TenantSettings(entriesWithValues .ToDictionary(pair => pair.Key, pair => new TenantSetting - { Value = pair.Value, IsEncrypted = encryptedKeys.Contains(pair.Key) }); + { Value = pair.Value, IsEncrypted = encryptedKeys.Contains(pair.Key) })); } return _cachedSettings; diff --git a/src/Infrastructure.Web.Hosting.Common/AspNetCallerContext.cs b/src/Infrastructure.Web.Hosting.Common/AspNetCallerContext.cs index 34a5c17d..b1648d97 100644 --- a/src/Infrastructure.Web.Hosting.Common/AspNetCallerContext.cs +++ b/src/Infrastructure.Web.Hosting.Common/AspNetCallerContext.cs @@ -6,12 +6,13 @@ using Domain.Interfaces; using Infrastructure.Common.Extensions; using Infrastructure.Interfaces; -using Infrastructure.Web.Api.Common; +using Infrastructure.Web.Api.Common.Endpoints; using Infrastructure.Web.Api.Common.Extensions; using Infrastructure.Web.Hosting.Common.Auth; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; namespace Infrastructure.Web.Hosting.Common; @@ -20,12 +21,13 @@ namespace Infrastructure.Web.Hosting.Common; /// internal sealed class AspNetCallerContext : ICallerContext { - public AspNetCallerContext(IHttpContextAccessor httpContext) + public AspNetCallerContext(IHttpContextAccessor httpContextAccessor) { - var context = httpContext.HttpContext!; - var claims = context.User.Claims.ToArray(); - TenantId = GetTenantId(context); - CallId = context.Items.TryGetValue(RequestCorrelationFilter.CorrelationIdItemName, + var httpContext = httpContextAccessor.HttpContext!; + var claims = httpContext.User.Claims.ToArray(); + var tenancyContext = httpContext.RequestServices.GetService(); + TenantId = GetTenantId(tenancyContext); + CallId = httpContext.Items.TryGetValue(RequestCorrelationFilter.CorrelationIdItemName, out var callId) ? callId!.ToString()! : Caller.GenerateCallId(); @@ -33,7 +35,7 @@ public AspNetCallerContext(IHttpContextAccessor httpContext) IsServiceAccount = CallerConstants.IsServiceAccount(CallerId); Roles = GetRoles(claims, TenantId); Features = GetFeatures(claims, TenantId); - Authorization = GetAuthorization(context); + Authorization = GetAuthorization(httpContext); IsAuthenticated = IsServiceAccount || (Authorization.HasValue && !CallerConstants.IsAnonymousUser(CallerId)); } @@ -54,13 +56,11 @@ public AspNetCallerContext(IHttpContextAccessor httpContext) public bool IsServiceAccount { get; } - // ReSharper disable once ReturnTypeCanBeNotNullable - // ReSharper disable once UnusedParameter.Local - private static string? GetTenantId(HttpContext context) + private static string? GetTenantId(ITenancyContext? tenancyContext) { - //HACK: if the request does not come in with an OrganizationId, then no tenant possible - return MultiTenancyConstants - .DefaultOrganizationId; //HACK: until we finish multi-tenancy , and fetch this from context.Items + return tenancyContext.Exists() + ? tenancyContext.Current + : null; } private static ICallerContext.CallerAuthorization GetAuthorization(HttpContext context) diff --git a/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs b/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs index 728ebf27..4eb07832 100644 --- a/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs +++ b/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs @@ -1,7 +1,10 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using Application.Interfaces; using Application.Interfaces.Services; +using Application.Persistence.Shared; +using Application.Services.Shared; using Common; using Common.Configuration; using Common.Extensions; @@ -14,7 +17,6 @@ using Domain.Interfaces.Services; using Domain.Shared; using Infrastructure.Common; -using Infrastructure.Common.DomainServices; using Infrastructure.Common.Extensions; using Infrastructure.Eventing.Common.Projections.ReadModels; using Infrastructure.Hosting.Common; @@ -23,6 +25,8 @@ using Infrastructure.Interfaces; using Infrastructure.Persistence.Common.ApplicationServices; using Infrastructure.Persistence.Interfaces; +using Infrastructure.Persistence.Shared.ApplicationServices; +using Infrastructure.Shared.ApplicationServices; using Infrastructure.Shared.ApplicationServices.External; using Infrastructure.Web.Api.Common; using Infrastructure.Web.Api.Common.Extensions; @@ -61,9 +65,10 @@ public static class HostExtensions #if TESTINGONLY private static readonly Dictionary StubQueueDrainingServiceQueuedApiMappings = new() { - { "audits", new DrainAllAuditsRequest() }, - { "usages", new DrainAllUsagesRequest() }, - { "emails", new DrainAllEmailsRequest() } + { WorkerConstants.Queues.Audits, new DrainAllAuditsRequest() }, + { WorkerConstants.Queues.Usages, new DrainAllUsagesRequest() }, + { WorkerConstants.Queues.Emails, new DrainAllEmailsRequest() }, + { WorkerConstants.Queues.Provisionings, new DrainAllProvisioningsRequest() } // { "events", new DrainAllEventsRequest() }, }; #endif @@ -78,11 +83,13 @@ public static WebApplication ConfigureApiHost(this WebApplicationBuilder appBuil RegisterConfiguration(hostOptions.IsMultiTenanted); RegisterRecording(); RegisterMultiTenancy(hostOptions.IsMultiTenanted); - RegisterAuthenticationAuthorization(hostOptions.Authorization); + RegisterAuthenticationAuthorization(hostOptions.Authorization, hostOptions.IsMultiTenanted); RegisterWireFormats(); RegisterApiRequests(); - RegisterApplicationServices(); - RegisterPersistence(hostOptions.Persistence.UsesQueues); + RegisterNotifications(hostOptions.UsesNotifications); + modules.RegisterServices(appBuilder.Configuration, appBuilder.Services); + RegisterApplicationServices(hostOptions.IsMultiTenanted); + RegisterPersistence(hostOptions.Persistence.UsesQueues, hostOptions.IsMultiTenanted); RegisterCors(hostOptions.CORS); var app = appBuilder.Build(); @@ -92,8 +99,7 @@ public static WebApplication ConfigureApiHost(this WebApplicationBuilder appBuil var middlewares = new List(); app.EnableRequestRewind(middlewares); app.AddExceptionShielding(middlewares); - app.AddBEFFE(middlewares, - hostOptions.IsBackendForFrontEnd); + app.AddBEFFE(middlewares, hostOptions.IsBackendForFrontEnd); app.EnableCORS(middlewares, hostOptions.CORS); app.EnableSecureAccess(middlewares, hostOptions.Authorization); app.EnableMultiTenancy(middlewares, hostOptions.IsMultiTenanted); @@ -112,7 +118,9 @@ public static WebApplication ConfigureApiHost(this WebApplicationBuilder appBuil void RegisterSharedServices() { appBuilder.Services.AddHttpContextAccessor(); - appBuilder.Services.AddSingleton(); + appBuilder.Services.AddSingleton(c => + new FlagsmithHttpServiceClient(c.ResolveForUnshared(), + c.ResolveForPlatform(), c.ResolveForUnshared())); } void RegisterConfiguration(bool isMultiTenanted) @@ -126,24 +134,20 @@ void RegisterConfiguration(bool isMultiTenanted) if (isMultiTenanted) { - appBuilder.Services - .RegisterUnshared(); - appBuilder.Services.RegisterUnshared(c => new TenantSettingService( - new AesEncryptionService(c - .ResolveForTenant().Platform - .GetString(TenantSettingService.EncryptionServiceSecretSettingName)))); appBuilder.Services.RegisterTenanted(c => - new AspNetConfigurationSettings(c.GetRequiredService(), + new AspNetDynamicConfigurationSettings(c.GetRequiredService(), c.ResolveForTenant())); } else { appBuilder.Services.RegisterUnshared(c => - new AspNetConfigurationSettings(c.GetRequiredService())); + new AspNetDynamicConfigurationSettings(c.GetRequiredService())); } + appBuilder.Services.RegisterPlatform(c => + new AspNetDynamicConfigurationSettings(c.GetRequiredService())); appBuilder.Services.RegisterUnshared(c => - new HostSettings(new AspNetConfigurationSettings(c.GetRequiredService()))); + new HostSettings(c.ResolveForPlatform())); } void RegisterRecording() @@ -179,8 +183,9 @@ void RegisterRecording() loggingBuilder.AddEventSourceLogger(); }); + // Note: IRecorder should always be not tenanted appBuilder.Services.RegisterUnshared(c => - new HostRecorder(c.ResolveForUnshared(), c.ResolveForUnshared(), + new HostRecorder(c.ResolveForPlatform(), c.ResolveForUnshared(), hostOptions)); } @@ -189,10 +194,11 @@ void RegisterMultiTenancy(bool isMultiTenanted) if (isMultiTenanted) { appBuilder.Services.RegisterTenanted(); + appBuilder.Services.RegisterTenanted(); } } - void RegisterAuthenticationAuthorization(AuthorizationOptions authentication) + void RegisterAuthenticationAuthorization(AuthorizationOptions authentication, bool isMultiTenanted) { if (authentication.HasNone) { @@ -267,9 +273,17 @@ void RegisterAuthenticationAuthorization(AuthorizationOptions authentication) } appBuilder.Services.AddAuthorization(); - appBuilder.Services.AddSingleton(); + if (isMultiTenanted) + { + appBuilder.Services.RegisterTenanted(); + } + else + { + appBuilder.Services.RegisterUnshared(); + } + appBuilder.Services - .AddSingleton(); + .RegisterUnshared(); if (authentication.UsesApiKeys || authentication.UsesTokens) { @@ -293,10 +307,28 @@ void RegisterApiRequests() appBuilder.Services.AddMediatR(configuration => { + // Here we want to register handlers in Transient lifetime, so that any services resolved within the handlers + //can be singletons, scoped, or transient (and use the same scope the handler is resolved in). + configuration.Lifetime = ServiceLifetime.Transient; configuration.RegisterServicesFromAssemblies(modules.ApiAssemblies.ToArray()) .AddValidatorBehaviors(validators, modules.ApiAssemblies); }); - modules.RegisterServices(appBuilder.Configuration, appBuilder.Services); + } + + void RegisterNotifications(bool usesNotifications) + { + if (usesNotifications) + { + appBuilder.Services.RegisterUnshared(c => + new EmailMessageQueue(c.Resolve(), c.Resolve(), + c.ResolveForPlatform())); + appBuilder.Services.RegisterUnshared(); + appBuilder.Services.RegisterUnshared(); + appBuilder.Services.RegisterUnshared(c => + new EmailNotificationsService(c.ResolveForPlatform(), + c.ResolveForUnshared(), c.ResolveForUnshared(), + c.ResolveForUnshared())); + } } void RegisterWireFormats() @@ -330,35 +362,60 @@ void RegisterWireFormats() }); } - void RegisterApplicationServices() + void RegisterApplicationServices(bool isMultiTenanted) { appBuilder.Services.AddHttpClient(); var prefixes = modules.AggregatePrefixes; prefixes.Add(typeof(Checkpoint), CheckPointAggregatePrefix); appBuilder.Services.RegisterUnshared(_ => new HostIdentifierFactory(prefixes)); - appBuilder.Services.AddSingleton(); + + if (isMultiTenanted) + { + appBuilder.Services.RegisterTenanted(); + } + else + { + appBuilder.Services.AddSingleton(); + } } - void RegisterPersistence(bool usesQueues) + void RegisterPersistence(bool usesQueues, bool isMultiTenanted) { - appBuilder.Services.RegisterUnshared(); - var domainAssemblies = modules.DomainAssemblies .Concat(new[] { typeof(DomainCommonMarker).Assembly, typeof(DomainSharedMarker).Assembly }) .ToArray(); - appBuilder.Services.RegisterUnshared(c => new DotNetDependencyContainer(c)); + + appBuilder.Services.RegisterPlatform(c => new DotNetDependencyContainer(c)); + if (isMultiTenanted) + { + appBuilder.Services.RegisterTenanted(c => new DotNetDependencyContainer(c)); + } + else + { + appBuilder.Services.RegisterUnshared(c => new DotNetDependencyContainer(c)); + } + + appBuilder.Services.RegisterUnshared(); appBuilder.Services.RegisterUnshared(c => DomainFactory.CreateRegistered( - c.ResolveForUnshared(), domainAssemblies)); + c.ResolveForPlatform(), domainAssemblies)); appBuilder.Services.RegisterUnshared(); #if TESTINGONLY - RegisterStoreForTestingOnly(appBuilder, usesQueues); + RegisterStoreForTestingOnly(appBuilder, usesQueues, isMultiTenanted); #else //HACK: we need a reasonable value for production here like SQLServerDataStore appBuilder.Services.RegisterPlatform(_ => NullStore.Instance); - appBuilder.Services.RegisterTenanted(_ => - NullStore.Instance); + if (isMultiTenanted) + { + appBuilder.Services.RegisterTenanted(_ => + NullStore.Instance); + } + else + { + appBuilder.Services.RegisterUnshared(_ => + NullStore.Instance); + } #endif } @@ -406,23 +463,33 @@ void RegisterCors(CORSOption cors) }); } #if TESTINGONLY - static void RegisterStoreForTestingOnly(WebApplicationBuilder appBuilder, bool usesQueues) + static void RegisterStoreForTestingOnly(WebApplicationBuilder appBuilder, bool usesQueues, bool isMultiTenanted) { appBuilder.Services .RegisterPlatform(c => - LocalMachineJsonFileStore.Create(c.ResolveForUnshared().Platform, - usesQueues - ? c.ResolveForUnshared() - : null)); - //HACK: In TESTINGONLY there won't be any physical partitioning of data for different tenants, - // even if the host is multi-tenanted. So we can register a singleton for this specific store, - // as we only ever want to resolve one instance for this store for all its uses (tenanted or unshared, except for platform use) - appBuilder.Services - .RegisterUnshared(c => - LocalMachineJsonFileStore.Create(c.ResolveForUnshared().Platform, + LocalMachineJsonFileStore.Create(c.ResolveForPlatform(), usesQueues ? c.ResolveForUnshared() : null)); + if (isMultiTenanted) + { + appBuilder.Services + .RegisterTenanted(c => + LocalMachineJsonFileStore.Create(c.ResolveForTenant(), + usesQueues + ? c.ResolveForUnshared() + : null)); + } + else + { + appBuilder.Services + .RegisterUnshared(c => + LocalMachineJsonFileStore.Create(c.ResolveForPlatform(), + usesQueues + ? c.ResolveForUnshared() + : null)); + } + if (usesQueues) { RegisterStubMessageQueueDrainingService(appBuilder); diff --git a/src/Infrastructure.Web.Hosting.Common/Extensions/WebApplicationExtensions.cs b/src/Infrastructure.Web.Hosting.Common/Extensions/WebApplicationExtensions.cs index 9b5b7d2b..9eaedbd8 100644 --- a/src/Infrastructure.Web.Hosting.Common/Extensions/WebApplicationExtensions.cs +++ b/src/Infrastructure.Web.Hosting.Common/Extensions/WebApplicationExtensions.cs @@ -6,6 +6,7 @@ using Infrastructure.Hosting.Common.Extensions; using Infrastructure.Persistence.Interfaces; using Infrastructure.Web.Api.Common; +using Infrastructure.Web.Api.Common.Endpoints; using Infrastructure.Web.Api.Common.Extensions; using Infrastructure.Web.Common; using Infrastructure.Web.Hosting.Common.ApplicationServices; @@ -170,7 +171,7 @@ public static void EnableMultiTenancy(this WebApplication builder, return; } - middlewares.Add(new MiddlewareRegistration(CustomMiddlewareIndex + 10, + middlewares.Add(new MiddlewareRegistration(52, //Must be after authentication and before Authorization app => { app.UseMiddleware(); }, "Pipeline: Multi-Tenancy detection is enabled")); } @@ -237,7 +238,8 @@ public static void EnableOtherFeatures(this WebApplication builder, /// /// Enables request buffering, so that request bodies can be read in filters. /// Note: Required to read the request by and by - /// during HMAC signature verification + /// during HMAC signature verification, and by + /// /// public static void EnableRequestRewind(this WebApplication builder, List middlewares) @@ -267,7 +269,7 @@ public static void EnableSecureAccess(this WebApplication builder, "Pipeline: Authentication is enabled: HMAC -> {HMAC}, APIKeys -> {APIKeys}, Tokens -> {Tokens}", authorization.UsesHMAC, authorization.UsesApiKeys, authorization.UsesTokens)); middlewares.Add( - new MiddlewareRegistration(52, app => { app.UseAuthorization(); }, + new MiddlewareRegistration(54, app => { app.UseAuthorization(); }, "Pipeline: Authorization is enabled: Roles -> Enabled, Features -> Enabled")); } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common/Infrastructure.Web.Hosting.Common.csproj b/src/Infrastructure.Web.Hosting.Common/Infrastructure.Web.Hosting.Common.csproj index f5032c76..c89c042a 100644 --- a/src/Infrastructure.Web.Hosting.Common/Infrastructure.Web.Hosting.Common.csproj +++ b/src/Infrastructure.Web.Hosting.Common/Infrastructure.Web.Hosting.Common.csproj @@ -30,6 +30,9 @@ <_Parameter1>Infrastructure.Web.Website.IntegrationTests + + <_Parameter1>Infrastructure.Web.Api.IntegrationTests + diff --git a/src/Infrastructure.Web.Hosting.Common/Pipeline/MultiTenancyMiddleware.cs b/src/Infrastructure.Web.Hosting.Common/Pipeline/MultiTenancyMiddleware.cs index dc292119..c9accc98 100644 --- a/src/Infrastructure.Web.Hosting.Common/Pipeline/MultiTenancyMiddleware.cs +++ b/src/Infrastructure.Web.Hosting.Common/Pipeline/MultiTenancyMiddleware.cs @@ -1,24 +1,264 @@ +using System.Reflection; +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Common.Extensions; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Interfaces; using Microsoft.AspNetCore.Http; namespace Infrastructure.Web.Hosting.Common.Pipeline; /// -/// Provides middleware to detect the tenant of incoming requests +/// Provides middleware to detect the tenant of incoming requests. +/// Detects the current tenant from either the request itself (as "OrganizationId"), +/// or, if missing, extracts the "DefaultOrganizationId" from the authenticated user +/// and rewrites that value into the body of POST/PUT/PATCH requests, of it has an "OrganizationId" /// public class MultiTenancyMiddleware { + private readonly IEndUsersService _endUsersService; + private readonly IIdentifierFactory _identifierFactory; private readonly RequestDelegate _next; + private readonly IOrganizationsService _organizationsService; - public MultiTenancyMiddleware(RequestDelegate next) + public MultiTenancyMiddleware(RequestDelegate next, IIdentifierFactory identifierFactory, + IEndUsersService endUsersService, IOrganizationsService organizationsService) { _next = next; + _identifierFactory = identifierFactory; + _endUsersService = endUsersService; + _organizationsService = organizationsService; } - public async Task InvokeAsync(HttpContext context) + public async Task InvokeAsync(HttpContext context, ITenancyContext tenancyContext, + ICallerContextFactory callerContextFactory, ITenantDetective tenantDetective) { - //TODO: We need a TenantDetective that extracts the TenantId from the request, - // or from the token being used + var caller = callerContextFactory.Create(); + var cancellationToken = context.RequestAborted; + + var result = await VerifyRequestAsync(caller, context, tenancyContext, tenantDetective, cancellationToken); + if (!result.IsSuccessful) + { + var httpError = result.Error.ToHttpError(); + var details = Results.Problem(statusCode: (int)httpError.Code, detail: httpError.Message); + await details + .ExecuteAsync(context); + return; + } await _next(context); //Continue down the pipeline } + + private async Task> VerifyRequestAsync(ICallerContext caller, HttpContext httpContext, + ITenancyContext tenancyContext, ITenantDetective tenantDetective, CancellationToken cancellationToken) + { + var requestDtoType = GetRequestDtoType(httpContext); + var detected = await tenantDetective.DetectTenantAsync(httpContext, requestDtoType, cancellationToken); + if (!detected.IsSuccessful) + { + return detected.Error; + } + + List? memberships = null; + var detectedResult = detected.Value; + var tenantId = detectedResult.TenantId.ValueOrDefault; + if (MissingRequiredTenantIdFromRequest(detectedResult)) + { + var defaultOrganizationId = + await VerifyDefaultOrganizationIdForCallerAsync(caller, memberships, cancellationToken); + if (!defaultOrganizationId.IsSuccessful) + { + return defaultOrganizationId.Error; + } + + if (defaultOrganizationId.Value.HasValue()) + { + tenantId = defaultOrganizationId.Value; + } + } + + if (tenantId.HasNoValue()) + { + return Result.Ok; + } + + var isMember = await VerifyCallerMembershipAsync(caller, memberships, tenantId, cancellationToken); + if (!isMember.IsSuccessful) + { + return isMember.Error; + } + + var set = await SetTenantIdAsync(caller, _identifierFactory, tenancyContext, _organizationsService, tenantId, + cancellationToken); + return set.IsSuccessful + ? Result.Ok + : set.Error; + } + + private static bool MissingRequiredTenantIdFromRequest(TenantDetectionResult detectedResult) + { + return detectedResult.ShouldHaveTenantId && detectedResult.TenantId.ValueOrDefault.HasNoValue(); + } + + private async Task> VerifyDefaultOrganizationIdForCallerAsync(ICallerContext caller, + List? memberships, CancellationToken cancellationToken) + { + if (memberships.NotExists()) + { + var retrievedMemberships = await GetMembershipsForCallerAsync(caller, cancellationToken); + if (!retrievedMemberships.IsSuccessful) + { + return retrievedMemberships.Error; + } + + memberships = retrievedMemberships.Value; + } + + var defaultOrganizationId = GetDefaultOrganizationId(memberships); + if (defaultOrganizationId.HasValue()) + { + return defaultOrganizationId; + } + + return Error.Validation(Resources.MultiTenancyMiddleware_MissingTenantId); + } + + private async Task> VerifyCallerMembershipAsync(ICallerContext caller, List? memberships, + string tenantId, CancellationToken cancellationToken) + { + if (!IsTenantedUser(caller)) + { + return Result.Ok; + } + + if (memberships.NotExists()) + { + var retrievedMemberships = await GetMembershipsForCallerAsync(caller, cancellationToken); + if (!retrievedMemberships.IsSuccessful) + { + return retrievedMemberships.Error; + } + + memberships = retrievedMemberships.Value; + } + + if (IsMemberOfOrganization(memberships, tenantId)) + { + return Result.Ok; + } + + return Error.ForbiddenAccess(Resources.MultiTenancyMiddleware_UserNotAMember.Format(tenantId)); + } + + /// + /// Validates the tenant ID and sets it in the , + /// and if necessary updates the request DTO with the tenant ID + /// + private static async Task> SetTenantIdAsync(ICallerContext caller, + IIdentifierFactory identifierFactory, ITenancyContext tenancyContext, + IOrganizationsService organizationsService, string tenantId, CancellationToken cancellationToken) + { + var isValid = IsTenantIdValid(identifierFactory, tenantId); + if (!isValid) + { + return Error.Validation(Resources.MultiTenancyMiddleware_InvalidTenantId); + } + + var settings = await organizationsService.GetSettingsPrivateAsync(caller, tenantId, cancellationToken); + if (!settings.IsSuccessful) + { + return settings.Error; + } + + tenancyContext.Set(tenantId, settings.Value); + + return Result.Ok; + } + + private static Optional GetRequestDtoType(HttpContext httpContext) + { + var endpoint = httpContext.GetEndpoint(); + if (endpoint.NotExists()) + { + return Optional.None; + } + + var method = endpoint.Metadata.GetMetadata(); + if (method.NotExists()) + { + return Optional.None; + } + + var args = method.GetParameters(); + if (args.Length < 2) + { + return Optional.None; + } + + var requestDtoType = args[1].ParameterType; + if (!requestDtoType.IsAssignableTo(typeof(IWebRequest))) + { + return Optional.None; + } + + return requestDtoType; + } + + private async Task, Error>> GetMembershipsForCallerAsync(ICallerContext caller, + CancellationToken cancellationToken) + { + if (!IsTenantedUser(caller)) + { + return new List(); + } + + var memberships = await _endUsersService.GetMembershipsPrivateAsync(caller, caller.CallerId, cancellationToken); + if (!memberships.IsSuccessful) + { + return memberships.Error; + } + + return memberships.Value.Memberships; + } + + private static bool IsTenantedUser(ICallerContext caller) + { + if (!caller.IsAuthenticated) + { + return false; + } + + return !caller.IsServiceAccount; + } + + private static string? GetDefaultOrganizationId(List memberships) + { + var defaultOrganization = memberships.FirstOrDefault(ms => ms.IsDefault); + if (defaultOrganization.Exists()) + { + return defaultOrganization.OrganizationId; + } + + return null; + } + + private static bool IsMemberOfOrganization(List memberships, string tenantId) + { + if (memberships.HasNone()) + { + return false; + } + + return memberships.Any(ms => ms.OrganizationId == tenantId); + } + + private static bool IsTenantIdValid(IIdentifierFactory identifierFactory, string tenantId) + { + return identifierFactory.IsValid(tenantId.ToId()); + } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common/Resources.Designer.cs b/src/Infrastructure.Web.Hosting.Common/Resources.Designer.cs index b6c27561..25e4e376 100644 --- a/src/Infrastructure.Web.Hosting.Common/Resources.Designer.cs +++ b/src/Infrastructure.Web.Hosting.Common/Resources.Designer.cs @@ -221,6 +221,33 @@ internal static string HMACAuthenticationHandler_MissingHeader { } } + /// + /// Looks up a localized string similar to The 'OrganizationId' of the request is invalid. + /// + internal static string MultiTenancyMiddleware_InvalidTenantId { + get { + return ResourceManager.GetString("MultiTenancyMiddleware_InvalidTenantId", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'OrganizationId' is missing from this request. + /// + internal static string MultiTenancyMiddleware_MissingTenantId { + get { + return ResourceManager.GetString("MultiTenancyMiddleware_MissingTenantId", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The authenticated user is not a member of organization '{0}'. + /// + internal static string MultiTenancyMiddleware_UserNotAMember { + get { + return ResourceManager.GetString("MultiTenancyMiddleware_UserNotAMember", resourceCulture); + } + } + /// /// Looks up a localized string similar to Invalid subscription level. /// diff --git a/src/Infrastructure.Web.Hosting.Common/Resources.resx b/src/Infrastructure.Web.Hosting.Common/Resources.resx index 8955f64f..18b07870 100644 --- a/src/Infrastructure.Web.Hosting.Common/Resources.resx +++ b/src/Infrastructure.Web.Hosting.Common/Resources.resx @@ -87,4 +87,13 @@ The Referer of the request does not match the server + + The 'OrganizationId' of the request is invalid + + + The 'OrganizationId' is missing from this request + + + The authenticated user is not a member of organization '{0}' + \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common/WebHostOptions.cs b/src/Infrastructure.Web.Hosting.Common/WebHostOptions.cs index 1ea12bac..7916914e 100644 --- a/src/Infrastructure.Web.Hosting.Common/WebHostOptions.cs +++ b/src/Infrastructure.Web.Hosting.Common/WebHostOptions.cs @@ -16,7 +16,8 @@ public class WebHostOptions : HostOptions UsesApiKeys = true, UsesHMAC = true }, - IsBackendForFrontEnd = false + IsBackendForFrontEnd = false, + UsesNotifications = true }; public new static readonly WebHostOptions BackEndApiHost = new(HostOptions.BackEndApiHost) { @@ -27,7 +28,8 @@ public class WebHostOptions : HostOptions UsesApiKeys = true, UsesHMAC = true }, - IsBackendForFrontEnd = false + IsBackendForFrontEnd = false, + UsesNotifications = true }; public new static readonly WebHostOptions BackEndForFrontEndWebHost = new(HostOptions.BackEndForFrontEndWebHost) @@ -39,7 +41,8 @@ public class WebHostOptions : HostOptions UsesApiKeys = false, UsesHMAC = false }, - IsBackendForFrontEnd = true + IsBackendForFrontEnd = true, + UsesNotifications = false }; public new static readonly WebHostOptions TestingStubsHost = new(HostOptions.TestingStubsHost) @@ -51,7 +54,8 @@ public class WebHostOptions : HostOptions UsesApiKeys = false, UsesHMAC = false }, - IsBackendForFrontEnd = false + IsBackendForFrontEnd = false, + UsesNotifications = false }; private WebHostOptions(HostOptions options) : base(options) @@ -66,6 +70,8 @@ private WebHostOptions(HostOptions options) : base(options) public CORSOption CORS { get; private init; } public bool IsBackendForFrontEnd { get; set; } + + public bool UsesNotifications { get; set; } } /// diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/AWSLambdas/AWSLambdaHostSetup.cs b/src/Infrastructure.Worker.Api.IntegrationTests/AWSLambdas/AWSLambdaHostSetup.cs index 126acaa0..d15b883d 100644 --- a/src/Infrastructure.Worker.Api.IntegrationTests/AWSLambdas/AWSLambdaHostSetup.cs +++ b/src/Infrastructure.Worker.Api.IntegrationTests/AWSLambdas/AWSLambdaHostSetup.cs @@ -26,9 +26,9 @@ public class AWSLambdaHostSetup : IApiWorkerSpec, IDisposable public AWSLambdaHostSetup() { - var settings = new AspNetConfigurationSettings(new ConfigurationBuilder() + var settings = new AspNetDynamicConfigurationSettings(new ConfigurationBuilder() .AddJsonFile("appsettings.Testing.json", true) - .Build()).Platform; + .Build()); var recorder = NullRecorder.Instance; QueueStore = AWSSQSQueueStore.Create(recorder, settings); AWSAccountBase.InitializeAllTests(); diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/AWSLambdas/AWSLambdasApiSpec.cs b/src/Infrastructure.Worker.Api.IntegrationTests/AWSLambdas/AWSLambdasApiSpec.cs index 0f386fa1..582e7b16 100644 --- a/src/Infrastructure.Worker.Api.IntegrationTests/AWSLambdas/AWSLambdasApiSpec.cs +++ b/src/Infrastructure.Worker.Api.IntegrationTests/AWSLambdas/AWSLambdasApiSpec.cs @@ -32,4 +32,13 @@ public DeliverEmailSpec(AWSLambdaHostSetup setup) : base(setup) { } } + + [Trait("Category", "Integration.External")] + [Collection("AWSLambdas")] + public class DeliverProvisioningSpec : DeliverProvisioningSpecBase + { + public DeliverProvisioningSpec(AWSLambdaHostSetup setup) : base(setup) + { + } + } } \ No newline at end of file diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/AzureFunctions/AzureFunctionHostSetup.cs b/src/Infrastructure.Worker.Api.IntegrationTests/AzureFunctions/AzureFunctionHostSetup.cs index 8988a2dc..51110899 100644 --- a/src/Infrastructure.Worker.Api.IntegrationTests/AzureFunctions/AzureFunctionHostSetup.cs +++ b/src/Infrastructure.Worker.Api.IntegrationTests/AzureFunctions/AzureFunctionHostSetup.cs @@ -29,9 +29,9 @@ public class AzureFunctionHostSetup : IApiWorkerSpec, IDisposable public AzureFunctionHostSetup() { - var settings = new AspNetConfigurationSettings(new ConfigurationBuilder() + var settings = new AspNetDynamicConfigurationSettings(new ConfigurationBuilder() .AddJsonFile("appsettings.Testing.json", true) - .Build()).Platform; + .Build()); var recorder = NullRecorder.Instance; QueueStore = AzureStorageAccountQueueStore.Create(recorder, settings); AzureStorageAccountBase.InitializeAllTests(); diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/AzureFunctions/AzureFunctionsApiSpec.cs b/src/Infrastructure.Worker.Api.IntegrationTests/AzureFunctions/AzureFunctionsApiSpec.cs index 51fa1572..1f4d8913 100644 --- a/src/Infrastructure.Worker.Api.IntegrationTests/AzureFunctions/AzureFunctionsApiSpec.cs +++ b/src/Infrastructure.Worker.Api.IntegrationTests/AzureFunctions/AzureFunctionsApiSpec.cs @@ -32,4 +32,13 @@ public DeliverEmailSpec(AzureFunctionHostSetup setup) : base(setup) { } } + + [Trait("Category", "Integration.External")] + [Collection("AzureFunctions")] + public class DeliverProvisioningSpec : DeliverProvisioningSpecBase + { + public DeliverProvisioningSpec(AzureFunctionHostSetup setup) : base(setup) + { + } + } } \ No newline at end of file diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/DeliverAuditSpecBase.cs b/src/Infrastructure.Worker.Api.IntegrationTests/DeliverAuditSpecBase.cs index 69c180e7..2b657955 100644 --- a/src/Infrastructure.Worker.Api.IntegrationTests/DeliverAuditSpecBase.cs +++ b/src/Infrastructure.Worker.Api.IntegrationTests/DeliverAuditSpecBase.cs @@ -1,10 +1,10 @@ +using Application.Interfaces; using Application.Persistence.Shared.ReadModels; using Common.Extensions; using FluentAssertions; using Infrastructure.Web.Api.Operations.Shared.Ancillary; using Infrastructure.Web.Interfaces.Clients; using Infrastructure.Worker.Api.IntegrationTests.Stubs; -using Infrastructure.Workers.Api.Workers; using Microsoft.Extensions.DependencyInjection; using UnitTesting.Common; using Xunit; @@ -18,7 +18,7 @@ public abstract class DeliverAuditSpecBase : ApiWorkerSpec protected DeliverAuditSpecBase(TSetup setup) : base(setup, OverrideDependencies) { - setup.QueueStore.DestroyAllAsync(DeliverUsageRelayWorker.QueueName, CancellationToken.None).GetAwaiter() + setup.QueueStore.DestroyAllAsync(WorkerConstants.Queues.Audits, CancellationToken.None).GetAwaiter() .GetResult(); _serviceClient = setup.GetRequiredService().As(); _serviceClient.Reset(); @@ -27,29 +27,29 @@ protected DeliverAuditSpecBase(TSetup setup) : base(setup, OverrideDependencies) [Fact] public async Task WhenMessageQueuedContainingInvalidContent_ThenApiNotCalled() { - await Setup.QueueStore.PushAsync(DeliverAuditRelayWorker.QueueName, "aninvalidusagemessage", + await Setup.QueueStore.PushAsync(WorkerConstants.Queues.Audits, "aninvalidmessage", CancellationToken.None); Setup.WaitForQueueProcessingToComplete(); - (await Setup.QueueStore.CountAsync(DeliverAuditRelayWorker.QueueName, CancellationToken.None)) + (await Setup.QueueStore.CountAsync(WorkerConstants.Queues.Audits, CancellationToken.None)) .Should().Be(0); _serviceClient.LastPostedMessage.Should().BeNone(); } [Fact] - public async Task WhenMessageQueuedContaining_ThenApiCalled() + public async Task WhenMessageQueued_ThenApiCalled() { var message = new AuditMessage { TenantId = "atenantid", AuditCode = "anauditcode" }.ToJson()!; - await Setup.QueueStore.PushAsync(DeliverAuditRelayWorker.QueueName, message, CancellationToken.None); + await Setup.QueueStore.PushAsync(WorkerConstants.Queues.Audits, message, CancellationToken.None); Setup.WaitForQueueProcessingToComplete(); - (await Setup.QueueStore.CountAsync(DeliverAuditRelayWorker.QueueName, CancellationToken.None)) + (await Setup.QueueStore.CountAsync(WorkerConstants.Queues.Audits, CancellationToken.None)) .Should().Be(0); _serviceClient.LastPostedMessage.Value.Should() .BeEquivalentTo(new DeliverAuditRequest { Message = message }); diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/DeliverEmailSpecBase.cs b/src/Infrastructure.Worker.Api.IntegrationTests/DeliverEmailSpecBase.cs index cec14ad1..e235ac61 100644 --- a/src/Infrastructure.Worker.Api.IntegrationTests/DeliverEmailSpecBase.cs +++ b/src/Infrastructure.Worker.Api.IntegrationTests/DeliverEmailSpecBase.cs @@ -1,9 +1,9 @@ +using Application.Interfaces; using Application.Persistence.Shared.ReadModels; using FluentAssertions; using Infrastructure.Web.Api.Operations.Shared.Ancillary; using Infrastructure.Web.Interfaces.Clients; using Infrastructure.Worker.Api.IntegrationTests.Stubs; -using Infrastructure.Workers.Api.Workers; using Microsoft.Extensions.DependencyInjection; using UnitTesting.Common; using Xunit; @@ -18,7 +18,7 @@ public abstract class DeliverEmailSpecBase : ApiWorkerSpec protected DeliverEmailSpecBase(TSetup setup) : base(setup, OverrideDependencies) { - setup.QueueStore.DestroyAllAsync(DeliverEmailRelayWorker.QueueName, CancellationToken.None).GetAwaiter() + setup.QueueStore.DestroyAllAsync(WorkerConstants.Queues.Emails, CancellationToken.None).GetAwaiter() .GetResult(); _serviceClient = setup.GetRequiredService().As(); _serviceClient.Reset(); @@ -27,18 +27,18 @@ protected DeliverEmailSpecBase(TSetup setup) : base(setup, OverrideDependencies) [Fact] public async Task WhenMessageQueuedContainingInvalidContent_ThenApiNotCalled() { - await Setup.QueueStore.PushAsync(DeliverEmailRelayWorker.QueueName, "aninvalidemailmessage", + await Setup.QueueStore.PushAsync(WorkerConstants.Queues.Emails, "aninvalidmessage", CancellationToken.None); Setup.WaitForQueueProcessingToComplete(); - (await Setup.QueueStore.CountAsync(DeliverEmailRelayWorker.QueueName, CancellationToken.None)) + (await Setup.QueueStore.CountAsync(WorkerConstants.Queues.Emails, CancellationToken.None)) .Should().Be(0); _serviceClient.LastPostedMessage.Should().BeNone(); } [Fact] - public async Task WhenMessageQueuedContaining_ThenApiCalled() + public async Task WhenMessageQueued_ThenApiCalled() { var message = StringExtensions.ToJson(new EmailMessage { @@ -50,11 +50,11 @@ public async Task WhenMessageQueuedContaining_ThenApiCalled() FromEmailAddress = "asenderemailaddress" } })!; - await Setup.QueueStore.PushAsync(DeliverEmailRelayWorker.QueueName, message, CancellationToken.None); + await Setup.QueueStore.PushAsync(WorkerConstants.Queues.Emails, message, CancellationToken.None); Setup.WaitForQueueProcessingToComplete(); - (await Setup.QueueStore.CountAsync(DeliverEmailRelayWorker.QueueName, CancellationToken.None)) + (await Setup.QueueStore.CountAsync(WorkerConstants.Queues.Emails, CancellationToken.None)) .Should().Be(0); _serviceClient.LastPostedMessage.Value.Should() .BeEquivalentTo(new DeliverEmailRequest { Message = message }); diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/DeliverProvisioningSpecBase.cs b/src/Infrastructure.Worker.Api.IntegrationTests/DeliverProvisioningSpecBase.cs new file mode 100644 index 00000000..52c229c1 --- /dev/null +++ b/src/Infrastructure.Worker.Api.IntegrationTests/DeliverProvisioningSpecBase.cs @@ -0,0 +1,68 @@ +using Application.Interfaces; +using Application.Interfaces.Services; +using Application.Persistence.Shared.ReadModels; +using FluentAssertions; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; +using Infrastructure.Web.Interfaces.Clients; +using Infrastructure.Worker.Api.IntegrationTests.Stubs; +using Microsoft.Extensions.DependencyInjection; +using UnitTesting.Common; +using Xunit; +using StringExtensions = Common.Extensions.StringExtensions; + +namespace Infrastructure.Worker.Api.IntegrationTests; + +public abstract class DeliverProvisioningSpecBase : ApiWorkerSpec + where TSetup : class, IApiWorkerSpec +{ + private readonly StubServiceClient _serviceClient; + + protected DeliverProvisioningSpecBase(TSetup setup) : base(setup, OverrideDependencies) + { + setup.QueueStore.DestroyAllAsync(WorkerConstants.Queues.Provisionings, CancellationToken.None).GetAwaiter() + .GetResult(); + _serviceClient = setup.GetRequiredService().As(); + _serviceClient.Reset(); + } + + [Fact] + public async Task WhenMessageQueuedContainingInvalidContent_ThenApiNotCalled() + { + await Setup.QueueStore.PushAsync(WorkerConstants.Queues.Provisionings, "aninvalidmessage", + CancellationToken.None); + + Setup.WaitForQueueProcessingToComplete(); + + (await Setup.QueueStore.CountAsync(WorkerConstants.Queues.Provisionings, CancellationToken.None)) + .Should().Be(0); + _serviceClient.LastPostedMessage.Should().BeNone(); + } + + [Fact] + public async Task WhenMessageQueued_ThenApiCalled() + { + var message = StringExtensions.ToJson(new ProvisioningMessage + { + TenantId = "anorganizationid", + Settings = new Dictionary + { + { "aname1", new TenantSetting("avalue") }, + { "aname2", new TenantSetting(99) }, + { "aname3", new TenantSetting(true) } + } + })!; + await Setup.QueueStore.PushAsync(WorkerConstants.Queues.Provisionings, message, CancellationToken.None); + + Setup.WaitForQueueProcessingToComplete(); + + (await Setup.QueueStore.CountAsync(WorkerConstants.Queues.Provisionings, CancellationToken.None)) + .Should().Be(0); + _serviceClient.LastPostedMessage.Value.Should() + .BeEquivalentTo(new DeliverProvisioningRequest { Message = message }); + } + + private static void OverrideDependencies(IServiceCollection services) + { + services.AddSingleton(); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/DeliverUsageSpecBase.cs b/src/Infrastructure.Worker.Api.IntegrationTests/DeliverUsageSpecBase.cs index f0fc15e5..51472f7b 100644 --- a/src/Infrastructure.Worker.Api.IntegrationTests/DeliverUsageSpecBase.cs +++ b/src/Infrastructure.Worker.Api.IntegrationTests/DeliverUsageSpecBase.cs @@ -1,9 +1,9 @@ +using Application.Interfaces; using Application.Persistence.Shared.ReadModels; using FluentAssertions; using Infrastructure.Web.Api.Operations.Shared.Ancillary; using Infrastructure.Web.Interfaces.Clients; using Infrastructure.Worker.Api.IntegrationTests.Stubs; -using Infrastructure.Workers.Api.Workers; using Microsoft.Extensions.DependencyInjection; using UnitTesting.Common; using Xunit; @@ -18,7 +18,7 @@ public abstract class DeliverUsageSpecBase : ApiWorkerSpec protected DeliverUsageSpecBase(TSetup setup) : base(setup, OverrideDependencies) { - setup.QueueStore.DestroyAllAsync(DeliverUsageRelayWorker.QueueName, CancellationToken.None).GetAwaiter() + setup.QueueStore.DestroyAllAsync(WorkerConstants.Queues.Usages, CancellationToken.None).GetAwaiter() .GetResult(); _serviceClient = setup.GetRequiredService().As(); _serviceClient.Reset(); @@ -27,29 +27,29 @@ protected DeliverUsageSpecBase(TSetup setup) : base(setup, OverrideDependencies) [Fact] public async Task WhenMessageQueuedContainingInvalidContent_ThenApiNotCalled() { - await Setup.QueueStore.PushAsync(DeliverUsageRelayWorker.QueueName, "aninvalidusagemessage", + await Setup.QueueStore.PushAsync(WorkerConstants.Queues.Usages, "aninvalidmessage", CancellationToken.None); Setup.WaitForQueueProcessingToComplete(); - (await Setup.QueueStore.CountAsync(DeliverUsageRelayWorker.QueueName, CancellationToken.None)) + (await Setup.QueueStore.CountAsync(WorkerConstants.Queues.Usages, CancellationToken.None)) .Should().Be(0); _serviceClient.LastPostedMessage.Should().BeNone(); } [Fact] - public async Task WhenMessageQueuedContaining_ThenApiCalled() + public async Task WhenMessageQueued_ThenApiCalled() { var message = StringExtensions.ToJson(new UsageMessage { ForId = "aforid", EventName = "aneventname" })!; - await Setup.QueueStore.PushAsync(DeliverUsageRelayWorker.QueueName, message, CancellationToken.None); + await Setup.QueueStore.PushAsync(WorkerConstants.Queues.Usages, message, CancellationToken.None); Setup.WaitForQueueProcessingToComplete(); - (await Setup.QueueStore.CountAsync(DeliverUsageRelayWorker.QueueName, CancellationToken.None)) + (await Setup.QueueStore.CountAsync(WorkerConstants.Queues.Usages, CancellationToken.None)) .Should().Be(0); _serviceClient.LastPostedMessage.Value.Should() .BeEquivalentTo(new DeliverUsageRequest { Message = message }); diff --git a/src/Infrastructure.Workers.Api/Workers/DeliverAuditRelayWorker.cs b/src/Infrastructure.Workers.Api/Workers/DeliverAuditRelayWorker.cs index b8a1fb03..fd4b2b09 100644 --- a/src/Infrastructure.Workers.Api/Workers/DeliverAuditRelayWorker.cs +++ b/src/Infrastructure.Workers.Api/Workers/DeliverAuditRelayWorker.cs @@ -10,7 +10,6 @@ namespace Infrastructure.Workers.Api.Workers; public sealed class DeliverAuditRelayWorker : IQueueMonitoringApiRelayWorker { - public const string QueueName = "audits"; private readonly IRecorder _recorder; private readonly IServiceClient _serviceClient; private readonly IHostSettings _settings; diff --git a/src/Infrastructure.Workers.Api/Workers/DeliverEmailRelayWorker.cs b/src/Infrastructure.Workers.Api/Workers/DeliverEmailRelayWorker.cs index ce7290e5..85501188 100644 --- a/src/Infrastructure.Workers.Api/Workers/DeliverEmailRelayWorker.cs +++ b/src/Infrastructure.Workers.Api/Workers/DeliverEmailRelayWorker.cs @@ -10,7 +10,6 @@ namespace Infrastructure.Workers.Api.Workers; public sealed class DeliverEmailRelayWorker : IQueueMonitoringApiRelayWorker { - public const string QueueName = "emails"; private readonly IRecorder _recorder; private readonly IServiceClient _serviceClient; private readonly IHostSettings _settings; diff --git a/src/Infrastructure.Workers.Api/Workers/DeliverProvisioningRelayWorker.cs b/src/Infrastructure.Workers.Api/Workers/DeliverProvisioningRelayWorker.cs new file mode 100644 index 00000000..200a9231 --- /dev/null +++ b/src/Infrastructure.Workers.Api/Workers/DeliverProvisioningRelayWorker.cs @@ -0,0 +1,32 @@ +using Application.Interfaces.Services; +using Application.Persistence.Shared.ReadModels; +using Common; +using Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; +using Infrastructure.Web.Interfaces.Clients; +using Task = System.Threading.Tasks.Task; + +namespace Infrastructure.Workers.Api.Workers; + +public sealed class DeliverProvisioningRelayWorker : IQueueMonitoringApiRelayWorker +{ + private readonly IRecorder _recorder; + private readonly IServiceClient _serviceClient; + private readonly IHostSettings _settings; + + public DeliverProvisioningRelayWorker(IRecorder recorder, IHostSettings settings, IServiceClient serviceClient) + { + _recorder = recorder; + _settings = settings; + _serviceClient = serviceClient; + } + + public async Task RelayMessageOrThrowAsync(ProvisioningMessage message, CancellationToken cancellationToken) + { + await _serviceClient.PostQueuedMessageToApiOrThrowAsync(_recorder, + message, new DeliverProvisioningRequest + { + Message = message.ToJson()! + }, _settings.GetAncillaryApiHostHmacAuthSecret(), cancellationToken); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Workers.Api/Workers/DeliverUsageRelayWorker.cs b/src/Infrastructure.Workers.Api/Workers/DeliverUsageRelayWorker.cs index 59f6662c..c429cd33 100644 --- a/src/Infrastructure.Workers.Api/Workers/DeliverUsageRelayWorker.cs +++ b/src/Infrastructure.Workers.Api/Workers/DeliverUsageRelayWorker.cs @@ -10,7 +10,6 @@ namespace Infrastructure.Workers.Api.Workers; public sealed class DeliverUsageRelayWorker : IQueueMonitoringApiRelayWorker { - public const string QueueName = "usages"; private readonly IRecorder _recorder; private readonly IServiceClient _serviceClient; private readonly IHostSettings _settings; diff --git a/src/IntegrationTesting.WebApi.Common/ExternalApiSpec.cs b/src/IntegrationTesting.WebApi.Common/ExternalApiSpec.cs index 8d9e2932..b1c629e7 100644 --- a/src/IntegrationTesting.WebApi.Common/ExternalApiSpec.cs +++ b/src/IntegrationTesting.WebApi.Common/ExternalApiSpec.cs @@ -72,7 +72,8 @@ public void Start() }) .ConfigureServices((context, services) => { - services.AddSingleton(new AspNetConfigurationSettings(context.Configuration)); + services.AddSingleton( + new AspNetDynamicConfigurationSettings(context.Configuration)); if (_overridenTestingDependencies.Exists()) { _overridenTestingDependencies.Invoke(services); diff --git a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs index 7bd5d010..0b1c34d4 100644 --- a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs +++ b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs @@ -105,6 +105,8 @@ public abstract class WebApiSpec : IClassFixture>, IDi { private const string DotNetCommandLineWithLaunchProfileArgumentsFormat = "run --no-build --configuration {0} --launch-profile {1} --project {2}"; + + private const string PasswordForPerson = "1Password!"; private const string WebServerBaseUrlFormat = "https://localhost:{0}/"; protected readonly IHttpJsonClient Api; protected readonly IHttpClient HttpApi; @@ -175,28 +177,21 @@ protected void EmptyAllRepositories() protected async Task LoginUserAsync(LoginUser who = LoginUser.PersonA) { - var emailAddress = who switch - { - LoginUser.PersonA => "person.a@company.com", - LoginUser.PersonB => "person.b@company.com", - LoginUser.Operator => "operator@company.com", - _ => throw new ArgumentOutOfRangeException(nameof(who), who, null) - }; + var emailAddress = GetEmailForPerson(who); var firstName = who switch { - LoginUser.PersonA => "aperson", - LoginUser.PersonB => "bperson", + LoginUser.PersonA => "persona", + LoginUser.PersonB => "personb", LoginUser.Operator => "operator", _ => throw new ArgumentOutOfRangeException(nameof(who), who, null) }; - const string password = "1Password!"; var person = await Api.PostAsync(new RegisterPersonPasswordRequest { EmailAddress = emailAddress, FirstName = firstName, LastName = "alastname", - Password = password, + Password = PasswordForPerson, TermsAndConditionsAccepted = true }); @@ -206,15 +201,22 @@ await Api.PostAsync(new ConfirmRegistrationPersonPasswordRequest Token = token! }); + return await ReAuthenticateUserAsync(person.Content.Value.Credential!.User, who); + } + + protected async Task ReAuthenticateUserAsync(RegisteredEndUser user, + LoginUser who = LoginUser.PersonA) + { + var emailAddress = GetEmailForPerson(who); + var login = await Api.PostAsync(new AuthenticatePasswordRequest { Username = emailAddress, - Password = password + Password = PasswordForPerson }); var accessToken = login.Content.Value.Tokens!.AccessToken.Value; var refreshToken = login.Content.Value.Tokens.RefreshToken.Value; - var user = person.Content.Value.Credential!.User; return new LoginDetails(accessToken, refreshToken, user); } @@ -254,6 +256,17 @@ protected void StartupServer() _additionalServerProcesses.Add(process.Id); } + private static string GetEmailForPerson(LoginUser who) + { + return who switch + { + LoginUser.PersonA => "person.a@company.com", + LoginUser.PersonB => "person.b@company.com", + LoginUser.Operator => "operator@company.com", + _ => throw new ArgumentOutOfRangeException(nameof(who), who, null) + }; + } + private static void ShutdownProcess(int processId) { if (processId != 0) diff --git a/src/OrganizationsApplication.UnitTests/OrganizationsApplication.UnitTests.csproj b/src/OrganizationsApplication.UnitTests/OrganizationsApplication.UnitTests.csproj new file mode 100644 index 00000000..561780bf --- /dev/null +++ b/src/OrganizationsApplication.UnitTests/OrganizationsApplication.UnitTests.csproj @@ -0,0 +1,19 @@ + + + + net7.0 + true + + + + + + + + + + + + + + diff --git a/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs b/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs new file mode 100644 index 00000000..0253664a --- /dev/null +++ b/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs @@ -0,0 +1,231 @@ +using Application.Interfaces; +using Application.Interfaces.Services; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces.Entities; +using Domain.Interfaces.Services; +using FluentAssertions; +using Moq; +using OrganizationsApplication.Persistence; +using OrganizationsDomain; +using UnitTesting.Common; +using Xunit; + +namespace OrganizationsApplication.UnitTests; + +[Trait("Category", "Unit")] +public class OrganizationsApplicationSpec +{ + private readonly OrganizationsApplication _application; + private readonly Mock _caller; + private readonly Mock _endUsersService; + private readonly Mock _idFactory; + private readonly Mock _recorder; + private readonly Mock _repository; + private readonly Mock _tenantSettingService; + private readonly Mock _tenantSettingsService; + + public OrganizationsApplicationSpec() + { + _recorder = new Mock(); + _caller = new Mock(); + _idFactory = new Mock(); + _idFactory.Setup(f => f.Create(It.IsAny())) + .Returns("anid".ToId()); + _tenantSettingsService = new Mock(); + _tenantSettingsService.Setup(tss => + tss.CreateForTenantAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new TenantSettings(new Dictionary + { + { "aname", "avalue" } + })); + _tenantSettingService = new Mock(); + _tenantSettingService.Setup(tss => tss.Encrypt(It.IsAny())) + .Returns((string value) => value); + _tenantSettingService.Setup(tss => tss.Decrypt(It.IsAny())) + .Returns((string value) => value); + _endUsersService = new Mock(); + _repository = new Mock(); + _repository.Setup(ar => ar.SaveAsync(It.IsAny(), It.IsAny())) + .Returns((OrganizationRoot root, CancellationToken _) => + Task.FromResult>(root)); + + _application = new OrganizationsApplication(_recorder.Object, _idFactory.Object, + _tenantSettingsService.Object, _tenantSettingService.Object, _endUsersService.Object, _repository.Object); + } + + [Fact] + public async Task WhenCreateOrganizationAsync_ThenReturnsOrganization() + { + var result = + await _application.CreateOrganizationAsync(_caller.Object, "auserid", "aname", + OrganizationOwnership.Personal, CancellationToken.None); + + result.Value.Name.Should().Be("aname"); + result.Value.Ownership.Should().Be(OrganizationOwnership.Personal); + result.Value.CreatedById.Should().Be("auserid"); + _repository.Verify(rep => rep.SaveAsync(It.Is(org => + org.Name == "aname" + && org.Ownership == Ownership.Personal + && org.CreatedById == "auserid" + && org.Settings.Properties.Count == 1 + && org.Settings.Properties["aname"].Value == "avalue" + && org.Settings.Properties["aname"].IsEncrypted == false + ), It.IsAny())); + _tenantSettingsService.Verify(tss => + tss.CreateForTenantAsync(_caller.Object, "anid", It.IsAny())); + } + + [Fact] + public async Task WhenCreateSharedOrganizationAsync_ThenReturnsSharedOrganization() + { + _caller.Setup(c => c.CallerId) + .Returns("acallerid"); + _endUsersService.Setup(eus => + eus.CreateMembershipForCallerPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new Membership + { + Id = "amembershipid", + OrganizationId = "anorganizationid", + IsDefault = false + }); + + var result = + await _application.CreateSharedOrganizationAsync(_caller.Object, "aname", + CancellationToken.None); + + result.Value.Name.Should().Be("aname"); + result.Value.Ownership.Should().Be(OrganizationOwnership.Shared); + result.Value.CreatedById.Should().Be("acallerid"); + _repository.Verify(rep => rep.SaveAsync(It.Is(org => + org.Name == "aname" + && org.Ownership == Ownership.Shared + && org.CreatedById == "acallerid" + && org.Settings.Properties.Count == 1 + && org.Settings.Properties["aname"].Value == "avalue" + && org.Settings.Properties["aname"].IsEncrypted == false + ), It.IsAny())); + _tenantSettingsService.Verify(tss => + tss.CreateForTenantAsync(_caller.Object, "anid", It.IsAny())); + _endUsersService.Verify(eus => + eus.CreateMembershipForCallerPrivateAsync(_caller.Object, "anid", It.IsAny())); + } + + [Fact] + public async Task WhenGetAndNotExists_ThenReturnsError() + { + _repository.Setup(s => + s.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = + await _application.GetOrganizationAsync(_caller.Object, "anorganizationid", CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task WhenGet_ThenReturnsOrganization() + { + var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, + Ownership.Personal, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + org.CreateSettings(Settings.Create(new Dictionary + { + { "aname", Setting.Create("avalue", true).Value } + }).Value); + _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(org); + + var result = + await _application.GetOrganizationAsync(_caller.Object, "anorganizationid", CancellationToken.None); + + result.Value.Name.Should().Be("aname"); + result.Value.CreatedById.Should().Be("auserid"); + result.Value.Ownership.Should().Be(OrganizationOwnership.Personal); + } + + [Fact] + public async Task WhenGetSettingsAndNotExists_ThenReturnsError() + { + _repository.Setup(s => + s.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = await _application.GetSettingsAsync(_caller.Object, "anorganizationid", CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task WhenGetSettings_ThenReturnsSettings() + { + var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, + Ownership.Personal, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + org.CreateSettings(Settings.Create(new Dictionary + { + { "aname", Setting.Create("avalue", true).Value } + }).Value); + _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(org); + + var result = await _application.GetSettingsAsync(_caller.Object, "anorganizationid", CancellationToken.None); + + result.Value.Count.Should().Be(1); + result.Value["aname"].Value.Should().Be("avalue"); + result.Value["aname"].IsEncrypted.Should().BeTrue(); + } + + [Fact] + public async Task WhenChangeSettingsAndNotExists_ThenReturnsError() + { + _repository.Setup(s => + s.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = await _application.ChangeSettingsAsync(_caller.Object, "anorganizationid", + new TenantSettings(), CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task WhenChangeSettings_ThenReturnsSettings() + { + var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, + Ownership.Personal, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + org.CreateSettings(Settings.Create(new Dictionary + { + { "aname1", Setting.Create("anoldvalue", true).Value }, + { "aname4", Setting.Create("anoldvalue", true).Value } + }).Value); + _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(org); + + var result = await _application.ChangeSettingsAsync(_caller.Object, "anorganizationid", + new TenantSettings(new Dictionary + { + { "aname1", "anewvalue" }, + { "aname2", 99 }, + { "aname3", true } + }), CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(rep => rep.SaveAsync(It.Is(o => + o.Name == "aname" + && o.Ownership == Ownership.Personal + && o.CreatedById == "auserid" + && o.Settings.Properties.Count == 4 + && o.Settings.Properties["aname1"].Value == "anewvalue" + && o.Settings.Properties["aname2"].Value == "99" + && o.Settings.Properties["aname3"].Value == "True" + && o.Settings.Properties["aname4"].Value == "anoldvalue" + ), It.IsAny())); + _tenantSettingsService.Verify(tss => + tss.CreateForTenantAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } +} \ No newline at end of file diff --git a/src/OrganizationsApplication/IOrganizationsApplication.cs b/src/OrganizationsApplication/IOrganizationsApplication.cs new file mode 100644 index 00000000..4c1560fe --- /dev/null +++ b/src/OrganizationsApplication/IOrganizationsApplication.cs @@ -0,0 +1,29 @@ +using Application.Interfaces; +using Application.Interfaces.Services; +using Application.Resources.Shared; +using Common; + +namespace OrganizationsApplication; + +public interface IOrganizationsApplication +{ + Task> ChangeSettingsAsync(ICallerContext caller, string id, + TenantSettings settings, CancellationToken cancellationToken); + + Task> CreateOrganizationAsync(ICallerContext caller, string creatorId, string name, + OrganizationOwnership ownership, CancellationToken cancellationToken); + + Task> CreateSharedOrganizationAsync(ICallerContext caller, string name, + CancellationToken cancellationToken); + + Task> GetOrganizationAsync(ICallerContext caller, string id, + CancellationToken cancellationToken); + +#if TESTINGONLY + Task> GetOrganizationSettingsAsync(ICallerContext caller, string id, + CancellationToken cancellationToken); +#endif + + Task> GetSettingsAsync(ICallerContext caller, string id, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/OrganizationsApplication/OrganizationsApplication.cs b/src/OrganizationsApplication/OrganizationsApplication.cs new file mode 100644 index 00000000..5e80dd38 --- /dev/null +++ b/src/OrganizationsApplication/OrganizationsApplication.cs @@ -0,0 +1,254 @@ +using Application.Common.Extensions; +using Application.Interfaces; +using Application.Interfaces.Services; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Common.Extensions; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces.Services; +using OrganizationsApplication.Persistence; +using OrganizationsDomain; + +namespace OrganizationsApplication; + +public class OrganizationsApplication : IOrganizationsApplication +{ + private readonly IEndUsersService _endUsersService; + private readonly IIdentifierFactory _identifierFactory; + private readonly IRecorder _recorder; + private readonly IOrganizationRepository _repository; + private readonly ITenantSettingService _tenantSettingService; + private readonly ITenantSettingsService _tenantSettingsService; + + public OrganizationsApplication(IRecorder recorder, IIdentifierFactory identifierFactory, + ITenantSettingsService tenantSettingsService, ITenantSettingService tenantSettingService, + IEndUsersService endUsersService, + IOrganizationRepository repository) + { + _recorder = recorder; + _identifierFactory = identifierFactory; + _tenantSettingService = tenantSettingService; + _endUsersService = endUsersService; + _tenantSettingsService = tenantSettingsService; + _repository = repository; + } + + public async Task> ChangeSettingsAsync(ICallerContext caller, string id, + TenantSettings settings, CancellationToken cancellationToken) + { + var retrieved = await _repository.LoadAsync(id.ToId(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var org = retrieved.Value; + var newSettings = settings.ToSettings(); + if (!newSettings.IsSuccessful) + { + return newSettings.Error; + } + + var updated = org.UpdateSettings(newSettings.Value); + if (!updated.IsSuccessful) + { + return updated.Error; + } + + var saved = await _repository.SaveAsync(org, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Updated the settings of organization: {Id}", org.Id); + + return Result.Ok; + } + + public async Task> CreateOrganizationAsync(ICallerContext caller, string creatorId, + string name, OrganizationOwnership ownership, CancellationToken cancellationToken) + { + var displayName = DisplayName.Create(name); + if (!displayName.IsSuccessful) + { + return displayName.Error; + } + + var created = OrganizationRoot.Create(_recorder, _identifierFactory, _tenantSettingService, + ownership.ToEnumOrDefault(Ownership.Shared), creatorId.ToId(), displayName.Value); + if (!created.IsSuccessful) + { + return created.Error; + } + + var org = created.Value; + var newSettings = await _tenantSettingsService.CreateForTenantAsync(caller, org.Id, cancellationToken); + if (!newSettings.IsSuccessful) + { + return newSettings.Error; + } + + var organizationSettings = newSettings.Value.ToSettings(); + if (!organizationSettings.IsSuccessful) + { + return organizationSettings.Error; + } + + var configured = org.CreateSettings(organizationSettings.Value); + if (!configured.IsSuccessful) + { + return configured.Error; + } + + //TODO: Get the billing details for the creator and add the billing subscription for them + + var saved = await _repository.SaveAsync(org, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Created organization: {Id}, by {CreatedBy}", org.Id, + saved.Value.CreatedById); + + return saved.Value.ToOrganization(); + } + + public async Task> CreateSharedOrganizationAsync(ICallerContext caller, string name, + CancellationToken cancellationToken) + { + var creatorId = caller.CallerId; + var created = await CreateOrganizationAsync(caller, creatorId, name, OrganizationOwnership.Shared, + cancellationToken); + if (!created.IsSuccessful) + { + return created.Error; + } + + var organization = created.Value; + var membership = + await _endUsersService.CreateMembershipForCallerPrivateAsync(caller, organization.Id, cancellationToken); + if (!membership.IsSuccessful) + { + return membership.Error; + } + + return organization; + } + + public async Task> GetOrganizationAsync(ICallerContext caller, string id, + CancellationToken cancellationToken) + { + var retrieved = await _repository.LoadAsync(id.ToId(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var org = retrieved.Value; + + _recorder.TraceInformation(caller.ToCall(), "Retrieved organization: {Id}", org.Id); + + return org.ToOrganization(); + } + +#if TESTINGONLY + public async Task> GetOrganizationSettingsAsync(ICallerContext caller, + string id, CancellationToken cancellationToken) + { + var retrieved = await _repository.LoadAsync(id.ToId(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var org = retrieved.Value; + + _recorder.TraceInformation(caller.ToCall(), "Retrieved organization: {Id}", org.Id); + + return org.ToOrganizationWithSettings(); + } +#endif + + public async Task> GetSettingsAsync(ICallerContext caller, string id, + CancellationToken cancellationToken) + { + var retrieved = await _repository.LoadAsync(id.ToId(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var org = retrieved.Value; + var settings = org.Settings; + + _recorder.TraceInformation(caller.ToCall(), "Retrieved organization: {Id} settings", org.Id); + + return settings.ToSettings(); + } +} + +internal static class OrganizationConversionExtensions +{ + public static Organization ToOrganization(this OrganizationRoot organization) + { + return new Organization + { + Id = organization.Id, + Name = organization.Name, + CreatedById = organization.CreatedById, + Ownership = organization.Ownership.ToEnumOrDefault(OrganizationOwnership.Shared) + }; + } + + public static OrganizationWithSettings ToOrganizationWithSettings(this OrganizationRoot organization) + { + var dto = organization.ToOrganization().Convert(); + dto.Settings = + organization.Settings.Properties.ToDictionary(pair => pair.Key, pair => (object?)pair.Value.Value); + return dto; + } + + public static Result ToSettings(this TenantSettings tenantSettings) + { + var settings = Settings.Empty; + foreach (var (key, tenantSetting) in tenantSettings) + { + if (tenantSetting.Value.NotExists()) + { + continue; + } + + var value = tenantSetting.Value.ToString()!; + var setting = Setting.Create(value, tenantSetting.IsEncrypted); + if (!setting.IsSuccessful) + { + return setting.Error; + } + + var added = settings.AddOrUpdate(key, setting.Value); + if (!added.IsSuccessful) + { + return added.Error; + } + + settings = added.Value; + } + + return settings; + } + + public static TenantSettings ToSettings(this Settings settings) + { + var dictionary = settings.Properties.ToDictionary(pair => pair.Key, pair => new TenantSetting + { + Value = pair.Value.Value, + IsEncrypted = pair.Value.IsEncrypted + }); + + return new TenantSettings(dictionary); + } +} \ No newline at end of file diff --git a/src/OrganizationsApplication/OrganizationsApplication.csproj b/src/OrganizationsApplication/OrganizationsApplication.csproj new file mode 100644 index 00000000..4c5764ef --- /dev/null +++ b/src/OrganizationsApplication/OrganizationsApplication.csproj @@ -0,0 +1,23 @@ + + + + net7.0 + + + + + + + + + + + + + + + <_Parameter1>$(AssemblyName).UnitTests + + + + diff --git a/src/OrganizationsApplication/Persistence/IOrganizationRepository.cs b/src/OrganizationsApplication/Persistence/IOrganizationRepository.cs new file mode 100644 index 00000000..7984f7a4 --- /dev/null +++ b/src/OrganizationsApplication/Persistence/IOrganizationRepository.cs @@ -0,0 +1,13 @@ +using Application.Persistence.Interfaces; +using Common; +using Domain.Common.ValueObjects; +using OrganizationsDomain; + +namespace OrganizationsApplication.Persistence; + +public interface IOrganizationRepository : IApplicationRepository +{ + Task> LoadAsync(Identifier id, CancellationToken cancellationToken); + + Task> SaveAsync(OrganizationRoot organization, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/OrganizationsApplication/Persistence/ReadModels/Organization.cs b/src/OrganizationsApplication/Persistence/ReadModels/Organization.cs new file mode 100644 index 00000000..060a306e --- /dev/null +++ b/src/OrganizationsApplication/Persistence/ReadModels/Organization.cs @@ -0,0 +1,16 @@ +using Application.Persistence.Common; +using Common; +using OrganizationsDomain; +using QueryAny; + +namespace OrganizationsApplication.Persistence.ReadModels; + +[EntityName("Organization")] +public class Organization : ReadModelEntity +{ + public Optional CreatedById { get; set; } + + public Optional Name { get; set; } + + public Optional Ownership { get; set; } +} \ No newline at end of file diff --git a/src/OrganizationsDomain.UnitTests/DisplayNameSpec.cs b/src/OrganizationsDomain.UnitTests/DisplayNameSpec.cs new file mode 100644 index 00000000..659b69a0 --- /dev/null +++ b/src/OrganizationsDomain.UnitTests/DisplayNameSpec.cs @@ -0,0 +1,27 @@ +using Common; +using FluentAssertions; +using UnitTesting.Common; +using Xunit; + +namespace OrganizationsDomain.UnitTests; + +[Trait("Category", "Unit")] +public class DisplayNameSpec +{ + [Fact] + public void WhenCreateWithInvalidName_ThenReturnsError() + { + var result = DisplayName.Create(string.Empty); + + result.Should().BeError(ErrorCode.Validation, Resources.OrganizationDisplayName_InvalidName); + } + + [Fact] + public void WhenCreate_ThenReturnsName() + { + var result = DisplayName.Create("aname"); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be("aname"); + } +} \ No newline at end of file diff --git a/src/OrganizationsDomain.UnitTests/OrganizationRootSpec.cs b/src/OrganizationsDomain.UnitTests/OrganizationRootSpec.cs new file mode 100644 index 00000000..f53e3296 --- /dev/null +++ b/src/OrganizationsDomain.UnitTests/OrganizationRootSpec.cs @@ -0,0 +1,79 @@ +using Common; +using Common.Extensions; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces.Entities; +using Domain.Interfaces.Services; +using FluentAssertions; +using Moq; +using Xunit; + +namespace OrganizationsDomain.UnitTests; + +[Trait("Category", "Unit")] +public class OrganizationRootSpec +{ + private readonly OrganizationRoot _org; + + public OrganizationRootSpec() + { + var recorder = new Mock(); + var identifierFactory = new Mock(); + identifierFactory.Setup(idf => idf.Create(It.IsAny())) + .Returns((IIdentifiableEntity _) => "anid".ToId()); + var tenantSettingService = new Mock(); + tenantSettingService.Setup(tss => tss.Encrypt(It.IsAny())) + .Returns((string value) => value); + tenantSettingService.Setup(tss => tss.Decrypt(It.IsAny())) + .Returns((string value) => value); + + _org = OrganizationRoot.Create(recorder.Object, identifierFactory.Object, tenantSettingService.Object, + Ownership.Personal, "acreatorid".ToId(), DisplayName.Create("aname").Value).Value; + } + + [Fact] + public void WhenCreate_ThenAssigns() + { + _org.Name.Name.Should().Be("aname"); + _org.CreatedById.Should().Be("acreatorid".ToId()); + _org.Ownership.Should().Be(Ownership.Personal); + _org.Settings.Should().Be(Settings.Empty); + } + + [Fact] + public void WhenCreateSettings_ThenAddsSettings() + { + _org.CreateSettings(Settings.Create(new Dictionary + { + { "aname1", Setting.Create("avalue1", true).Value }, + { "aname2", Setting.Create("avalue2", true).Value } + }).Value); + + _org.Settings.Properties.Count.Should().Be(2); + _org.Settings.Properties["aname1"].Should().Be(Setting.Create("avalue1", true).Value); + _org.Settings.Properties["aname2"].Should().Be(Setting.Create("avalue2", true).Value); + _org.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenUpdateSettings_ThenAddsAndUpdatesSettings() + { + _org.CreateSettings(Settings.Create(new Dictionary + { + { "aname1", Setting.Create("anoldvalue1", false).Value }, + { "aname2", Setting.Create("anoldvalue2", false).Value } + }).Value); + _org.UpdateSettings(Settings.Create(new Dictionary + { + { "aname1", Setting.Create("anewvalue1", true).Value }, + { "aname3", Setting.Create("anewvalue3", true).Value } + }).Value); + + _org.Settings.Properties.Count.Should().Be(3); + _org.Settings.Properties["aname1"].Should().Be(Setting.Create("anewvalue1", true).Value); + _org.Settings.Properties["aname2"].Should().Be(Setting.Create("anoldvalue2", false).Value); + _org.Settings.Properties["aname3"].Should().Be(Setting.Create("anewvalue3", true).Value); + _org.Events[3].Should().BeOfType(); + _org.Events.Last().Should().BeOfType(); + } +} \ No newline at end of file diff --git a/src/OrganizationsDomain.UnitTests/OrganizationsDomain.UnitTests.csproj b/src/OrganizationsDomain.UnitTests/OrganizationsDomain.UnitTests.csproj new file mode 100644 index 00000000..b5ad511a --- /dev/null +++ b/src/OrganizationsDomain.UnitTests/OrganizationsDomain.UnitTests.csproj @@ -0,0 +1,18 @@ + + + + net7.0 + true + + + + + + + + + + + + + diff --git a/src/OrganizationsDomain.UnitTests/SettingSpec.cs b/src/OrganizationsDomain.UnitTests/SettingSpec.cs new file mode 100644 index 00000000..c74fe74b --- /dev/null +++ b/src/OrganizationsDomain.UnitTests/SettingSpec.cs @@ -0,0 +1,29 @@ +using FluentAssertions; +using UnitTesting.Common; +using Xunit; + +namespace OrganizationsDomain.UnitTests; + +[Trait("Category", "Unit")] +public class SettingSpec +{ + [Fact] + public void WhenCreateWithEmptyValue_ThenReturnsSetting() + { + var result = Setting.Create(string.Empty, true); + + result.Should().BeSuccess(); + result.Value.Value.Should().BeEmpty(); + result.Value.IsEncrypted.Should().BeTrue(); + } + + [Fact] + public void WhenCreateWithNonEmptyValue_ThenReturnsSetting() + { + var result = Setting.Create("aname", true); + + result.Should().BeSuccess(); + result.Value.Value.Should().Be("aname"); + result.Value.IsEncrypted.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/src/OrganizationsDomain.UnitTests/SettingsSpec.cs b/src/OrganizationsDomain.UnitTests/SettingsSpec.cs new file mode 100644 index 00000000..9442ddc8 --- /dev/null +++ b/src/OrganizationsDomain.UnitTests/SettingsSpec.cs @@ -0,0 +1,64 @@ +using FluentAssertions; +using UnitTesting.Common; +using Xunit; + +namespace OrganizationsDomain.UnitTests; + +[Trait("Category", "Unit")] +public class SettingsSpec +{ + [Fact] + public void WhenCreateWithEmptyCollection_ThenReturnsSettings() + { + var result = Settings.Create(new Dictionary()); + + result.Should().BeSuccess(); + result.Value.Properties.Should().BeEmpty(); + } + + [Fact] + public void WhenCreateWithCollection_ThenReturnsSettings() + { + var result = Settings.Create(new Dictionary + { + { "aname1", Setting.Create("avalue1", true).Value }, + { "aname2", Setting.Create("avalue2", false).Value } + }); + + result.Should().BeSuccess(); + result.Value.Properties.Count.Should().Be(2); + result.Value.Properties["aname1"].Value.Should().Be("avalue1"); + result.Value.Properties["aname1"].IsEncrypted.Should().BeTrue(); + result.Value.Properties["aname2"].Value.Should().Be("avalue2"); + result.Value.Properties["aname2"].IsEncrypted.Should().BeFalse(); + } + + [Fact] + public void WhenAddOrUpdateAndNotExist_ThenAdds() + { + var settings = Settings.Create(new Dictionary()).Value; + + var result = settings.AddOrUpdate("aname", "avalue", true); + + result.Should().BeSuccess(); + result.Value.Properties.Count.Should().Be(1); + result.Value.Properties["aname"].Value.Should().Be("avalue"); + result.Value.Properties["aname"].IsEncrypted.Should().BeTrue(); + } + + [Fact] + public void WhenAddOrUpdateAndExists_ThenUpdates() + { + var settings = Settings.Create(new Dictionary + { + { "aname", Setting.Create("anoldvalue", true).Value } + }).Value; + + var result = settings.AddOrUpdate("aname", "anewvalue", false); + + result.Should().BeSuccess(); + result.Value.Properties.Count.Should().Be(1); + result.Value.Properties["aname"].Value.Should().Be("anewvalue"); + result.Value.Properties["aname"].IsEncrypted.Should().BeFalse(); + } +} \ No newline at end of file diff --git a/src/OrganizationsDomain/DisplayName.cs b/src/OrganizationsDomain/DisplayName.cs new file mode 100644 index 00000000..0d96a210 --- /dev/null +++ b/src/OrganizationsDomain/DisplayName.cs @@ -0,0 +1,37 @@ +using Common; +using Domain.Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + +namespace OrganizationsDomain; + +public class DisplayName : SingleValueObjectBase +{ + public static readonly DisplayName Empty = new(string.Empty); + + public static Result Create(string value) + { + if (value.IsInvalidParameter(Validations.Organization.DisplayName, nameof(value), + Resources.OrganizationDisplayName_InvalidName, out var error)) + { + return error; + } + + return new DisplayName(value); + } + + private DisplayName(string name) : base(name) + { + } + + public string Name => Value; + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => + { + var parts = RehydrateToList(property, true); + return new DisplayName(parts[0]!); + }; + } +} \ No newline at end of file diff --git a/src/OrganizationsDomain/Events.cs b/src/OrganizationsDomain/Events.cs new file mode 100644 index 00000000..6b54f324 --- /dev/null +++ b/src/OrganizationsDomain/Events.cs @@ -0,0 +1,85 @@ +using Domain.Common.ValueObjects; +using Domain.Interfaces.Entities; + +namespace OrganizationsDomain; + +public static class Events +{ + public class Created : IDomainEvent + { + public static Created Create(Identifier id, Ownership ownership, Identifier createdBy, DisplayName name) + { + return new Created + { + Name = name, + Ownership = ownership, + CreatedById = createdBy, + RootId = id, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string CreatedById { get; set; } + + public required string Name { get; set; } + + public Ownership Ownership { get; set; } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + + public class SettingCreated : IDomainEvent + { + public static SettingCreated Create(Identifier id, string name, string value, bool isEncrypted) + { + return new SettingCreated + { + RootId = id, + Name = name, + Value = value, + IsEncrypted = isEncrypted, + OccurredUtc = DateTime.UtcNow + }; + } + + public bool IsEncrypted { get; set; } + + public required string Name { get; set; } + + public required string Value { get; set; } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + + public class SettingUpdated : IDomainEvent + { + public static SettingUpdated Create(Identifier id, string name, string from, string to, bool isEncrypted) + { + return new SettingUpdated + { + RootId = id, + Name = name, + From = from, + To = to, + IsEncrypted = isEncrypted, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string From { get; set; } + + public bool IsEncrypted { get; set; } + + public required string Name { get; set; } + + public required string To { get; set; } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } +} \ No newline at end of file diff --git a/src/OrganizationsDomain/OrganizationRoot.cs b/src/OrganizationsDomain/OrganizationRoot.cs new file mode 100644 index 00000000..84e2e91e --- /dev/null +++ b/src/OrganizationsDomain/OrganizationRoot.cs @@ -0,0 +1,160 @@ +using Common; +using Domain.Common.Entities; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.Entities; +using Domain.Interfaces.Services; +using Domain.Interfaces.ValueObjects; + +namespace OrganizationsDomain; + +public sealed class OrganizationRoot : AggregateRootBase +{ + private readonly ITenantSettingService _tenantSettingService; + + public static Result Create(IRecorder recorder, IIdentifierFactory idFactory, + ITenantSettingService tenantSettingService, Ownership ownership, Identifier createdBy, DisplayName name) + { + var root = new OrganizationRoot(recorder, idFactory, tenantSettingService); + root.RaiseCreateEvent(OrganizationsDomain.Events.Created.Create(root.Id, ownership, createdBy, name)); + return root; + } + + private OrganizationRoot(IRecorder recorder, IIdentifierFactory idFactory, + ITenantSettingService tenantSettingService) : + base(recorder, idFactory) + { + _tenantSettingService = tenantSettingService; + } + + private OrganizationRoot(IRecorder recorder, IIdentifierFactory idFactory, + ITenantSettingService tenantSettingService, + ISingleValueObject identifier) : base( + recorder, idFactory, identifier) + { + _tenantSettingService = tenantSettingService; + } + + public Identifier CreatedById { get; private set; } = Identifier.Empty(); + + public DisplayName Name { get; private set; } = DisplayName.Empty; + + public Ownership Ownership { get; private set; } + + public Settings Settings { get; private set; } = Settings.Empty; + + public static AggregateRootFactory Rehydrate() + { + return (identifier, container, _) => new OrganizationRoot(container.Resolve(), + container.Resolve(), container.Resolve(), identifier); + } + + public override Result EnsureInvariants() + { + var ensureInvariants = base.EnsureInvariants(); + if (!ensureInvariants.IsSuccessful) + { + return ensureInvariants.Error; + } + + //TODO: add your other invariant rules here + + return Result.Ok; + } + + protected override Result OnStateChanged(IDomainEvent @event, bool isReconstituting) + { + switch (@event) + { + case Events.Created created: + { + var name = DisplayName.Create(created.Name); + if (!name.IsSuccessful) + { + return name.Error; + } + + Name = name.Value; + Ownership = created.Ownership; + CreatedById = created.CreatedById.ToId(); + return Result.Ok; + } + + case Events.SettingCreated created: + { + var value = created.IsEncrypted + ? _tenantSettingService.Decrypt(created.Value) + : created.Value; + + var settings = Settings.AddOrUpdate(created.Name, value, created.IsEncrypted); + if (!settings.IsSuccessful) + { + return settings.Error; + } + + Settings = settings.Value; + Recorder.TraceDebug(null, "Organization {Id} created settings", Id); + return Result.Ok; + } + + case Events.SettingUpdated updated: + { + var to = updated.IsEncrypted + ? _tenantSettingService.Decrypt(updated.To) + : updated.To; + + var settings = Settings.AddOrUpdate(updated.Name, to, updated.IsEncrypted); + if (!settings.IsSuccessful) + { + return settings.Error; + } + + Settings = settings.Value; + Recorder.TraceDebug(null, "Organization {Id} created settings", Id); + return Result.Ok; + } + + default: + return HandleUnKnownStateChangedEvent(@event); + } + } + + public Result CreateSettings(Settings settings) + { + foreach (var (key, value) in settings.Properties) + { + var valueValue = value.IsEncrypted + ? _tenantSettingService.Encrypt(value.Value) + : value.Value; + RaiseChangeEvent(OrganizationsDomain.Events.SettingCreated.Create(Id, key, valueValue, value.IsEncrypted)); + } + + return Result.Ok; + } + + public Result UpdateSettings(Settings settings) + { + foreach (var (key, value) in settings.Properties) + { + if (Settings.TryGet(key, out var oldSetting)) + { + var valueValue = value.IsEncrypted + ? _tenantSettingService.Encrypt(value.Value) + : value.Value; + RaiseChangeEvent(OrganizationsDomain.Events.SettingUpdated.Create(Id, key, oldSetting!.Value, + valueValue, value.IsEncrypted)); + } + else + { + var valueValue = value.IsEncrypted + ? _tenantSettingService.Encrypt(value.Value) + : value.Value; + RaiseChangeEvent( + OrganizationsDomain.Events.SettingCreated.Create(Id, key, valueValue, value.IsEncrypted)); + } + } + + return Result.Ok; + } +} \ No newline at end of file diff --git a/src/OrganizationsDomain/OrganizationsDomain.csproj b/src/OrganizationsDomain/OrganizationsDomain.csproj new file mode 100644 index 00000000..1348a8ff --- /dev/null +++ b/src/OrganizationsDomain/OrganizationsDomain.csproj @@ -0,0 +1,34 @@ + + + + net7.0 + + + + + + + + + + + <_Parameter1>$(AssemblyName).UnitTests + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + True + True + Resources.resx + + + + diff --git a/src/OrganizationsDomain/Ownership.cs b/src/OrganizationsDomain/Ownership.cs new file mode 100644 index 00000000..de152a1d --- /dev/null +++ b/src/OrganizationsDomain/Ownership.cs @@ -0,0 +1,7 @@ +namespace OrganizationsDomain; + +public enum Ownership +{ + Shared = 0, + Personal = 1 +} \ No newline at end of file diff --git a/src/OrganizationsDomain/Resources.Designer.cs b/src/OrganizationsDomain/Resources.Designer.cs new file mode 100644 index 00000000..170e7ea1 --- /dev/null +++ b/src/OrganizationsDomain/Resources.Designer.cs @@ -0,0 +1,71 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace OrganizationsDomain { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("OrganizationsDomain.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to The name is invalid. + /// + internal static string OrganizationDisplayName_InvalidName { + get { + return ResourceManager.GetString("OrganizationDisplayName_InvalidName", resourceCulture); + } + } + } +} diff --git a/src/OrganizationsDomain/Resources.resx b/src/OrganizationsDomain/Resources.resx new file mode 100644 index 00000000..ceaa92c3 --- /dev/null +++ b/src/OrganizationsDomain/Resources.resx @@ -0,0 +1,29 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + The name is invalid + + \ No newline at end of file diff --git a/src/OrganizationsDomain/Setting.cs b/src/OrganizationsDomain/Setting.cs new file mode 100644 index 00000000..0685cdd4 --- /dev/null +++ b/src/OrganizationsDomain/Setting.cs @@ -0,0 +1,38 @@ +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + +namespace OrganizationsDomain; + +public class Setting : ValueObjectBase +{ + public static Result Create(string value, bool isEncrypted) + { + return new Setting(value, isEncrypted); + } + + private Setting(string value, bool isEncrypted) + { + Value = value; + IsEncrypted = isEncrypted; + } + + public bool IsEncrypted { get; } + + public string Value { get; } + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => + { + var parts = RehydrateToList(property, false); + return new Setting(parts[0]!, parts[1].ToBool()); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new object[] { Value, IsEncrypted }; + } +} \ No newline at end of file diff --git a/src/OrganizationsDomain/Settings.cs b/src/OrganizationsDomain/Settings.cs new file mode 100644 index 00000000..619633b1 --- /dev/null +++ b/src/OrganizationsDomain/Settings.cs @@ -0,0 +1,73 @@ +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.ValueObjects; + +namespace OrganizationsDomain; + +public class Settings : ValueObjectBase +{ + public static readonly Settings Empty = new(); + + public static Result Create(Dictionary properties) + { + return new Settings(properties); + } + + private Settings() + { + Properties = new Dictionary(); + } + + private Settings(Dictionary properties) + { + Properties = properties; + } + + public Dictionary Properties { get; } + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => + { + var parts = RehydrateToList(property, false); + return new Settings(parts[0]!.FromJson>()!); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new[] { Properties.ToJson()! }; + } + + public Result AddOrUpdate(string name, string value, bool isEncrypted) + { + var settingValue = Setting.Create(value, isEncrypted); + if (!settingValue.IsSuccessful) + { + return settingValue.Error; + } + + return AddOrUpdate(name, settingValue.Value); + } + + public Result AddOrUpdate(string name, Setting setting) + { + var settings = new Settings(new Dictionary(Properties)) + { + Properties = + { + [name] = setting + } + }; + + return settings; + } + + [SkipImmutabilityCheck] + public bool TryGet(string key, out Setting? setting) + { + return Properties.TryGetValue(key, out setting); + } +} \ No newline at end of file diff --git a/src/OrganizationsDomain/Validations.cs b/src/OrganizationsDomain/Validations.cs new file mode 100644 index 00000000..468a8a7f --- /dev/null +++ b/src/OrganizationsDomain/Validations.cs @@ -0,0 +1,11 @@ +using Domain.Interfaces.Validations; + +namespace OrganizationsDomain; + +public static class Validations +{ + public static class Organization + { + public static readonly Validation DisplayName = CommonValidations.DescriptiveName(); + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs b/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs new file mode 100644 index 00000000..54a540fb --- /dev/null +++ b/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs @@ -0,0 +1,49 @@ +using ApiHost1; +using Application.Resources.Shared; +using FluentAssertions; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared.Organizations; +using IntegrationTesting.WebApi.Common; +using Xunit; + +namespace OrganizationsInfrastructure.IntegrationTests; + +[Trait("Category", "Integration.Web")] +[Collection("API")] +public class OrganizationsApiSpec : WebApiSpec +{ + public OrganizationsApiSpec(WebApiSetup setup) : base(setup) + { + EmptyAllRepositories(); + } + + [Fact] + public async Task WhenGetDefaultOrganization_ThenReturnsOrganization() + { + var login = await LoginUserAsync(); + + var result = await Api.GetAsync(new GetOrganizationRequest + { + Id = login.User.Profile?.DefaultOrganizationId! + }, req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Organization!.CreatedById.Should().Be(login.User.Id); + result.Content.Value.Organization!.Name.Should().Be("persona alastname"); + result.Content.Value.Organization!.Ownership.Should().Be(OrganizationOwnership.Personal); + } + + [Fact] + public async Task WhenCreateOrganization_ThenReturnsOrganization() + { + var login = await LoginUserAsync(LoginUser.Operator); + + var result = await Api.PostAsync(new CreateOrganizationRequest + { + Name = "anorganizationname" + }, req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Organization!.CreatedById.Should().Be(login.User.Id); + result.Content.Value.Organization!.Name.Should().Be("anorganizationname"); + result.Content.Value.Organization!.Ownership.Should().Be(OrganizationOwnership.Shared); + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsInfrastructure.IntegrationTests.csproj b/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsInfrastructure.IntegrationTests.csproj new file mode 100644 index 00000000..246ee85d --- /dev/null +++ b/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsInfrastructure.IntegrationTests.csproj @@ -0,0 +1,23 @@ + + + + net7.0 + true + + + + + + + + + + + + + + Always + + + + diff --git a/src/OrganizationsInfrastructure.IntegrationTests/appsettings.Testing.json b/src/OrganizationsInfrastructure.IntegrationTests/appsettings.Testing.json new file mode 100644 index 00000000..bad65ee6 --- /dev/null +++ b/src/OrganizationsInfrastructure.IntegrationTests/appsettings.Testing.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + "ApplicationServices": { + "Persistence": { + "LocalMachineJsonFileStore": { + "RootPath": "./saastack/testing/organizations" + } + } + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure.UnitTests/Api/Organizations/CreateOrganizationRequestValidatorSpec.cs b/src/OrganizationsInfrastructure.UnitTests/Api/Organizations/CreateOrganizationRequestValidatorSpec.cs new file mode 100644 index 00000000..73d88988 --- /dev/null +++ b/src/OrganizationsInfrastructure.UnitTests/Api/Organizations/CreateOrganizationRequestValidatorSpec.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.Organizations; +using OrganizationsInfrastructure.Api.Organizations; +using UnitTesting.Common.Validation; +using Xunit; + +namespace OrganizationsInfrastructure.UnitTests.Api.Organizations; + +[Trait("Category", "Unit")] +public class CreateOrganizationRequestValidatorSpec +{ + private readonly CreateOrganizationRequest _dto; + private readonly CreateOrganizationRequestValidator _validator; + + public CreateOrganizationRequestValidatorSpec() + { + _validator = new CreateOrganizationRequestValidator(); + _dto = new CreateOrganizationRequest + { + Name = "aname" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenNameIsEmpty_ThenThrows() + { + _dto.Name = string.Empty; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.CreateOrganizationRequestValidator_InvalidName); + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure.UnitTests/OrganizationsInfrastructure.UnitTests.csproj b/src/OrganizationsInfrastructure.UnitTests/OrganizationsInfrastructure.UnitTests.csproj new file mode 100644 index 00000000..6028b5a1 --- /dev/null +++ b/src/OrganizationsInfrastructure.UnitTests/OrganizationsInfrastructure.UnitTests.csproj @@ -0,0 +1,19 @@ + + + + net7.0 + true + + + + + + + + + + + + + + diff --git a/src/OrganizationsInfrastructure/Api/Organizations/CreateOrganizationRequestValidator.cs b/src/OrganizationsInfrastructure/Api/Organizations/CreateOrganizationRequestValidator.cs new file mode 100644 index 00000000..25584f82 --- /dev/null +++ b/src/OrganizationsInfrastructure/Api/Organizations/CreateOrganizationRequestValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Organizations; +using OrganizationsDomain; + +namespace OrganizationsInfrastructure.Api.Organizations; + +public class CreateOrganizationRequestValidator : AbstractValidator +{ + public CreateOrganizationRequestValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .Matches(Validations.Organization.DisplayName) + .WithMessage(Resources.CreateOrganizationRequestValidator_InvalidName); + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/Api/Organizations/GetOrganizationRequestValidator.cs b/src/OrganizationsInfrastructure/Api/Organizations/GetOrganizationRequestValidator.cs new file mode 100644 index 00000000..501e493b --- /dev/null +++ b/src/OrganizationsInfrastructure/Api/Organizations/GetOrganizationRequestValidator.cs @@ -0,0 +1,17 @@ +using Domain.Common.Identity; +using Domain.Interfaces.Validations; +using FluentValidation; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Organizations; + +namespace OrganizationsInfrastructure.Api.Organizations; + +public class GetOrganizationRequestValidator : AbstractValidator +{ + public GetOrganizationRequestValidator(IIdentifierFactory identifierFactory) + { + RuleFor(x => x.Id) + .IsEntityId(identifierFactory) + .WithMessage(CommonValidationResources.AnyValidator_InvalidId); + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs b/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs new file mode 100644 index 00000000..7bdc732f --- /dev/null +++ b/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs @@ -0,0 +1,60 @@ +using Application.Resources.Shared; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared.Organizations; +using OrganizationsApplication; + +namespace OrganizationsInfrastructure.Api.Organizations; + +public class OrganizationsApi : IWebApiService +{ + private readonly ICallerContextFactory _contextFactory; + private readonly IOrganizationsApplication _organizationsApplication; + + public OrganizationsApi(ICallerContextFactory contextFactory, IOrganizationsApplication organizationsApplication) + { + _contextFactory = contextFactory; + _organizationsApplication = organizationsApplication; + } + + public async Task> Create(CreateOrganizationRequest request, + CancellationToken cancellationToken) + { + var organization = + await _organizationsApplication.CreateSharedOrganizationAsync(_contextFactory.Create(), request.Name, + cancellationToken); + + return () => organization.HandleApplicationResult(org => + new PostResult(new GetOrganizationResponse { Organization = org })); + } + + public async Task> Get(GetOrganizationRequest request, + CancellationToken cancellationToken) + { + var organization = + await _organizationsApplication.GetOrganizationAsync(_contextFactory.Create(), request.Id, + cancellationToken); + + return () => + organization.HandleApplicationResult(org => + new GetOrganizationResponse { Organization = org }); + } +#if TESTINGONLY + public async Task> GetSettings( + GetOrganizationSettingsRequest request, CancellationToken cancellationToken) + { + var organization = + await _organizationsApplication.GetOrganizationSettingsAsync(_contextFactory.Create(), request.Id, + cancellationToken); + + return () => + organization.HandleApplicationResult(org => + new GetOrganizationSettingsResponse + { + Organization = org, + Settings = org.Settings + }); + } +#endif +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/ApplicationServices/OrganizationsInProcessServiceClient.cs b/src/OrganizationsInfrastructure/ApplicationServices/OrganizationsInProcessServiceClient.cs new file mode 100644 index 00000000..867ac5d5 --- /dev/null +++ b/src/OrganizationsInfrastructure/ApplicationServices/OrganizationsInProcessServiceClient.cs @@ -0,0 +1,55 @@ +using Application.Interfaces; +using Application.Interfaces.Services; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Common.Extensions; +using OrganizationsApplication; + +namespace OrganizationsInfrastructure.ApplicationServices; + +public class OrganizationsInProcessServiceClient : IOrganizationsService +{ + private readonly Func _organizationsApplicationFactory; + private IOrganizationsApplication? _application; + + /// + /// HACK: LazyResolve and is needed here to avoid the runtime cyclic dependency between + /// requiring , and + /// requiring + /// + public OrganizationsInProcessServiceClient(Func organizationsApplicationFactory) + { + _organizationsApplicationFactory = organizationsApplicationFactory; + } + + public async Task> ChangeSettingsPrivateAsync(ICallerContext caller, string id, + TenantSettings settings, + CancellationToken cancellationToken) + { + return await GetApplication().ChangeSettingsAsync(caller, id, settings, cancellationToken); + } + + public async Task> CreateOrganizationPrivateAsync(ICallerContext caller, + string creatorId, string name, OrganizationOwnership ownership, CancellationToken cancellationToken) + { + return await GetApplication().CreateOrganizationAsync(caller, creatorId, name, ownership, + cancellationToken); + } + + public async Task> GetSettingsPrivateAsync(ICallerContext caller, string id, + CancellationToken cancellationToken) + { + return await GetApplication().GetSettingsAsync(caller, id, cancellationToken); + } + + private IOrganizationsApplication GetApplication() + { + if (_application.NotExists()) + { + _application = _organizationsApplicationFactory(); + } + + return _application; + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/OrganizationsInfrastructure.csproj b/src/OrganizationsInfrastructure/OrganizationsInfrastructure.csproj new file mode 100644 index 00000000..e62dcb7a --- /dev/null +++ b/src/OrganizationsInfrastructure/OrganizationsInfrastructure.csproj @@ -0,0 +1,41 @@ + + + + net7.0 + + + + + + + + + + + + + + + <_Parameter1>$(AssemblyName).UnitTests + + + + + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + True + True + Resources.resx + + + + diff --git a/src/OrganizationsInfrastructure/OrganizationsModule.cs b/src/OrganizationsInfrastructure/OrganizationsModule.cs new file mode 100644 index 00000000..1ddbafb2 --- /dev/null +++ b/src/OrganizationsInfrastructure/OrganizationsModule.cs @@ -0,0 +1,76 @@ +using System.Reflection; +using Application.Interfaces.Services; +using Application.Persistence.Interfaces; +using Application.Services.Shared; +using Common; +using Common.Configuration; +using Domain.Common.Identity; +using Domain.Interfaces; +using Domain.Interfaces.Services; +using Infrastructure.Common.DomainServices; +using Infrastructure.Hosting.Common.Extensions; +using Infrastructure.Persistence.Interfaces; +using Infrastructure.Web.Hosting.Common; +using Infrastructure.Web.Hosting.Common.ApplicationServices; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OrganizationsApplication; +using OrganizationsApplication.Persistence; +using OrganizationsDomain; +using OrganizationsInfrastructure.ApplicationServices; +using OrganizationsInfrastructure.Persistence; +using OrganizationsInfrastructure.Persistence.ReadModels; + +namespace OrganizationsInfrastructure; + +public class OrganizationsModule : ISubDomainModule +{ + public Assembly ApiAssembly => typeof(OrganizationsModule).Assembly; + + public Assembly DomainAssembly => typeof(OrganizationRoot).Assembly; + + public Dictionary AggregatePrefixes => new() + { + { typeof(OrganizationRoot), "org" } + }; + + public Action> ConfigureMiddleware + { + get { return (app, _) => app.RegisterRoutes(); } + } + + public Action RegisterServices + { + get + { + return (_, services) => + { + services.RegisterUnshared(); + services.RegisterUnshared(c => new TenantSettingService( + new AesEncryptionService(c + .ResolveForPlatform() + .GetString(TenantSettingService.EncryptionServiceSecretSettingName)))); + services.RegisterUnshared(c => + new OrganizationsApplication.OrganizationsApplication(c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForUnshared())); + services.RegisterUnshared(c => new OrganizationRepository( + c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForUnshared>(), + c.ResolveForPlatform())); + services.RegisterUnTenantedEventing( + c => new OrganizationProjection(c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForPlatform())); + + services.RegisterUnshared(c => + new OrganizationsInProcessServiceClient(c.LazyResolveForUnshared())); + }; + } + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/Persistence/OrganizationRepository.cs b/src/OrganizationsInfrastructure/Persistence/OrganizationRepository.cs new file mode 100644 index 00000000..b79804b9 --- /dev/null +++ b/src/OrganizationsInfrastructure/Persistence/OrganizationRepository.cs @@ -0,0 +1,55 @@ +using Application.Persistence.Interfaces; +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Infrastructure.Persistence.Common; +using Infrastructure.Persistence.Interfaces; +using OrganizationsApplication.Persistence; +using OrganizationsApplication.Persistence.ReadModels; +using OrganizationsDomain; + +namespace OrganizationsInfrastructure.Persistence; + +public class OrganizationRepository : IOrganizationRepository +{ + private readonly ISnapshottingQueryStore _organizationQueries; + private readonly IEventSourcingDddCommandStore _organizations; + + public OrganizationRepository(IRecorder recorder, IDomainFactory domainFactory, + IEventSourcingDddCommandStore organizationsStore, IDataStore store) + { + _organizationQueries = new SnapshottingQueryStore(recorder, domainFactory, store); + _organizations = organizationsStore; + } + + public async Task> DestroyAllAsync(CancellationToken cancellationToken) + { + return await Tasks.WhenAllAsync( + _organizationQueries.DestroyAllAsync(cancellationToken), + _organizations.DestroyAllAsync(cancellationToken)); + } + + public async Task> LoadAsync(Identifier id, CancellationToken cancellationToken) + { + var organization = await _organizations.LoadAsync(id, cancellationToken); + if (!organization.IsSuccessful) + { + return organization.Error; + } + + return organization; + } + + public async Task> SaveAsync(OrganizationRoot organization, + CancellationToken cancellationToken) + { + var saved = await _organizations.SaveAsync(organization, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + return organization; + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/Persistence/ReadModels/OrganizationProjection.cs b/src/OrganizationsInfrastructure/Persistence/ReadModels/OrganizationProjection.cs new file mode 100644 index 00000000..6b0cbb83 --- /dev/null +++ b/src/OrganizationsInfrastructure/Persistence/ReadModels/OrganizationProjection.cs @@ -0,0 +1,46 @@ +using Application.Persistence.Common.Extensions; +using Application.Persistence.Interfaces; +using Common; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.Entities; +using Infrastructure.Persistence.Common; +using Infrastructure.Persistence.Interfaces; +using OrganizationsApplication.Persistence.ReadModels; +using OrganizationsDomain; + +namespace OrganizationsInfrastructure.Persistence.ReadModels; + +public class OrganizationProjection : IReadModelProjection +{ + private readonly IReadModelProjectionStore _organizations; + + public OrganizationProjection(IRecorder recorder, IDomainFactory domainFactory, IDataStore store) + { + _organizations = new ReadModelProjectionStore(recorder, domainFactory, store); + } + + public Type RootAggregateType => typeof(OrganizationRoot); + + public async Task> ProjectEventAsync(IDomainEvent changeEvent, + CancellationToken cancellationToken) + { + switch (changeEvent) + { + case Events.Created e: + return await _organizations.HandleCreateAsync(e.RootId.ToId(), dto => + { + dto.Name = e.Name; + dto.Ownership = e.Ownership; + dto.CreatedById = e.CreatedById; + }, + cancellationToken); + + case Events.SettingCreated _: + return true; + + default: + return false; + } + } +} \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/Resources.Designer.cs b/src/OrganizationsInfrastructure/Resources.Designer.cs new file mode 100644 index 00000000..e5a93d97 --- /dev/null +++ b/src/OrganizationsInfrastructure/Resources.Designer.cs @@ -0,0 +1,71 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace OrganizationsInfrastructure { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("OrganizationsInfrastructure.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to The 'Name' is either missing or invalid. + /// + internal static string CreateOrganizationRequestValidator_InvalidName { + get { + return ResourceManager.GetString("CreateOrganizationRequestValidator_InvalidName", resourceCulture); + } + } + } +} diff --git a/src/OrganizationsInfrastructure/Resources.resx b/src/OrganizationsInfrastructure/Resources.resx new file mode 100644 index 00000000..20b040b5 --- /dev/null +++ b/src/OrganizationsInfrastructure/Resources.resx @@ -0,0 +1,29 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + The 'Name' is either missing or invalid + + \ No newline at end of file diff --git a/src/SaaStack.sln b/src/SaaStack.sln index 8e6c2d51..a4848427 100644 --- a/src/SaaStack.sln +++ b/src/SaaStack.sln @@ -30,7 +30,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EndUsers", "EndUsers", "{80 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Organizations", "Organizations", "{2FFC0771-965C-4C3F-9E42-AD871D7EF463}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Profiles", "Profiles", "{8BB22358-7F43-462F-B26E-D83B9A4711CC}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UserProfiles", "UserProfiles", "{8BB22358-7F43-462F-B26E-D83B9A4711CC}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Subscriptions", "Subscriptions", "{124D8FF5-43D1-4019-B07C-7F55DC4A1807}" EndProject @@ -304,6 +304,22 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tools.Generators.Common.Uni EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Shared.IntegrationTests", "Infrastructure.Shared.IntegrationTests\Infrastructure.Shared.IntegrationTests.csproj", "{A4E40A61-6C36-4C1E-B5D5-68546B2387C3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrganizationsDomain", "OrganizationsDomain\OrganizationsDomain.csproj", "{B0245CF3-0D8D-45FA-889F-EEA42D5A8FEB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrganizationsInfrastructure", "OrganizationsInfrastructure\OrganizationsInfrastructure.csproj", "{84A43D97-0448-453A-B700-068AA5F9A896}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrganizationsApplication", "OrganizationsApplication\OrganizationsApplication.csproj", "{A7FCBC99-8D3A-4C4A-A321-4CC717D7D46B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{90ED1D1C-5960-4F56-94F1-8063490725C4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrganizationsDomain.UnitTests", "OrganizationsDomain.UnitTests\OrganizationsDomain.UnitTests.csproj", "{C4741A39-17C4-42FC-8D98-21EA8DDE2AB4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrganizationsApplication.UnitTests", "OrganizationsApplication.UnitTests\OrganizationsApplication.UnitTests.csproj", "{5546E812-E7FC-45C9-B744-72786FB0F2E6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrganizationsInfrastructure.IntegrationTests", "OrganizationsInfrastructure.IntegrationTests\OrganizationsInfrastructure.IntegrationTests.csproj", "{4DBA0B71-413D-43A9-AF68-150135A74F5B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrganizationsInfrastructure.UnitTests", "OrganizationsInfrastructure.UnitTests\OrganizationsInfrastructure.UnitTests.csproj", "{B2ABB588-A7D4-44DB-8A2B-C1657D57D546}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -953,6 +969,48 @@ Global {A4E40A61-6C36-4C1E-B5D5-68546B2387C3}.Release|Any CPU.Build.0 = Release|Any CPU {A4E40A61-6C36-4C1E-B5D5-68546B2387C3}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU {A4E40A61-6C36-4C1E-B5D5-68546B2387C3}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {B0245CF3-0D8D-45FA-889F-EEA42D5A8FEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B0245CF3-0D8D-45FA-889F-EEA42D5A8FEB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B0245CF3-0D8D-45FA-889F-EEA42D5A8FEB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B0245CF3-0D8D-45FA-889F-EEA42D5A8FEB}.Release|Any CPU.Build.0 = Release|Any CPU + {B0245CF3-0D8D-45FA-889F-EEA42D5A8FEB}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {B0245CF3-0D8D-45FA-889F-EEA42D5A8FEB}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {84A43D97-0448-453A-B700-068AA5F9A896}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {84A43D97-0448-453A-B700-068AA5F9A896}.Debug|Any CPU.Build.0 = Debug|Any CPU + {84A43D97-0448-453A-B700-068AA5F9A896}.Release|Any CPU.ActiveCfg = Release|Any CPU + {84A43D97-0448-453A-B700-068AA5F9A896}.Release|Any CPU.Build.0 = Release|Any CPU + {84A43D97-0448-453A-B700-068AA5F9A896}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {84A43D97-0448-453A-B700-068AA5F9A896}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {A7FCBC99-8D3A-4C4A-A321-4CC717D7D46B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7FCBC99-8D3A-4C4A-A321-4CC717D7D46B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7FCBC99-8D3A-4C4A-A321-4CC717D7D46B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7FCBC99-8D3A-4C4A-A321-4CC717D7D46B}.Release|Any CPU.Build.0 = Release|Any CPU + {A7FCBC99-8D3A-4C4A-A321-4CC717D7D46B}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {A7FCBC99-8D3A-4C4A-A321-4CC717D7D46B}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {C4741A39-17C4-42FC-8D98-21EA8DDE2AB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4741A39-17C4-42FC-8D98-21EA8DDE2AB4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4741A39-17C4-42FC-8D98-21EA8DDE2AB4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4741A39-17C4-42FC-8D98-21EA8DDE2AB4}.Release|Any CPU.Build.0 = Release|Any CPU + {C4741A39-17C4-42FC-8D98-21EA8DDE2AB4}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {C4741A39-17C4-42FC-8D98-21EA8DDE2AB4}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {5546E812-E7FC-45C9-B744-72786FB0F2E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5546E812-E7FC-45C9-B744-72786FB0F2E6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5546E812-E7FC-45C9-B744-72786FB0F2E6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5546E812-E7FC-45C9-B744-72786FB0F2E6}.Release|Any CPU.Build.0 = Release|Any CPU + {5546E812-E7FC-45C9-B744-72786FB0F2E6}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {5546E812-E7FC-45C9-B744-72786FB0F2E6}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {4DBA0B71-413D-43A9-AF68-150135A74F5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4DBA0B71-413D-43A9-AF68-150135A74F5B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4DBA0B71-413D-43A9-AF68-150135A74F5B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4DBA0B71-413D-43A9-AF68-150135A74F5B}.Release|Any CPU.Build.0 = Release|Any CPU + {4DBA0B71-413D-43A9-AF68-150135A74F5B}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {4DBA0B71-413D-43A9-AF68-150135A74F5B}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU + {B2ABB588-A7D4-44DB-8A2B-C1657D57D546}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2ABB588-A7D4-44DB-8A2B-C1657D57D546}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2ABB588-A7D4-44DB-8A2B-C1657D57D546}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2ABB588-A7D4-44DB-8A2B-C1657D57D546}.Release|Any CPU.Build.0 = Release|Any CPU + {B2ABB588-A7D4-44DB-8A2B-C1657D57D546}.ReleaseForDeploy|Any CPU.ActiveCfg = ReleaseForDeploy|Any CPU + {B2ABB588-A7D4-44DB-8A2B-C1657D57D546}.ReleaseForDeploy|Any CPU.Build.0 = ReleaseForDeploy|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {F5C77A86-38AF-40E4-82FC-617E624B2754} = {508E7DA4-4DF2-4201-955D-CCF70C41AD05} @@ -1047,7 +1105,6 @@ Global {0104A4C6-811F-4AD3-B365-9BE0054CE706} = {600081A4-A5E3-48ED-85F2-A36F52C1A459} {032ED9AF-8BB5-404A-B81D-EDEED9291C22} = {600081A4-A5E3-48ED-85F2-A36F52C1A459} {E458A872-783B-4F84-B0A6-0903EB9F3EF8} = {2D85C956-5EFD-4BA5-93EA-5990FC83B99B} - {EA58877D-3023-429C-A1A8-E8479441139E} = {E458A872-783B-4F84-B0A6-0903EB9F3EF8} {B610A0C7-B573-4B25-A9F6-B6E5C4593722} = {5838EE94-374F-4A6F-A231-1BC1C87985F4} {3782A767-2274-4F44-80C6-D6C6EEB9C9A5} = {864DED88-9252-46EB-9D13-00269C7333F9} {580F13F5-712D-4284-946B-7192FBB2CBA8} = {3782A767-2274-4F44-80C6-D6C6EEB9C9A5} @@ -1099,5 +1156,14 @@ Global {578736A6-7CE1-408D-8217-468F35861F5B} = {BAE0D6F2-6920-4B02-9F30-D71B04B7170D} {6C654E34-B698-4F23-8757-D50C85F51F5B} = {A25A3BA8-5602-4825-9595-2CF96B166920} {A4E40A61-6C36-4C1E-B5D5-68546B2387C3} = {9B6B0235-BD3F-4604-8E93-B0112A241C63} + {B0245CF3-0D8D-45FA-889F-EEA42D5A8FEB} = {2FFC0771-965C-4C3F-9E42-AD871D7EF463} + {84A43D97-0448-453A-B700-068AA5F9A896} = {2FFC0771-965C-4C3F-9E42-AD871D7EF463} + {A7FCBC99-8D3A-4C4A-A321-4CC717D7D46B} = {2FFC0771-965C-4C3F-9E42-AD871D7EF463} + {90ED1D1C-5960-4F56-94F1-8063490725C4} = {2FFC0771-965C-4C3F-9E42-AD871D7EF463} + {C4741A39-17C4-42FC-8D98-21EA8DDE2AB4} = {90ED1D1C-5960-4F56-94F1-8063490725C4} + {5546E812-E7FC-45C9-B744-72786FB0F2E6} = {90ED1D1C-5960-4F56-94F1-8063490725C4} + {4DBA0B71-413D-43A9-AF68-150135A74F5B} = {90ED1D1C-5960-4F56-94F1-8063490725C4} + {B2ABB588-A7D4-44DB-8A2B-C1657D57D546} = {90ED1D1C-5960-4F56-94F1-8063490725C4} + {EA58877D-3023-429C-A1A8-E8479441139E} = {D3B68FF7-293B-4458-B8D8-49D3DF59B495} EndGlobalSection EndGlobal diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index faee57f0..d1a4d27c 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -8,6 +8,7 @@ WARNING WARNING + HINT DO_NOT_SHOW HINT DO_NOT_SHOW @@ -797,8 +798,10 @@ public void When$condition$_Then$outcome$() True True True + True True True + True True True True @@ -872,7 +875,10 @@ public void When$condition$_Then$outcome$() True True True + True True + True + True True True True @@ -880,14 +886,17 @@ public void When$condition$_Then$outcome$() True True True + True True True True + True True True True True True + True True True True @@ -922,6 +931,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -966,6 +976,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1015,11 +1026,14 @@ public void When$condition$_Then$outcome$() True True True + True True + True True True True True + True True True True @@ -1030,12 +1044,15 @@ public void When$condition$_Then$outcome$() True True True + True + True True True True True True True + True True True True diff --git a/src/Tools.Generators.Web.Api.UnitTests/MinimalApiMediatRGeneratorSpec.cs b/src/Tools.Generators.Web.Api.UnitTests/MinimalApiMediatRGeneratorSpec.cs index 601282de..89660ffa 100644 --- a/src/Tools.Generators.Web.Api.UnitTests/MinimalApiMediatRGeneratorSpec.cs +++ b/src/Tools.Generators.Web.Api.UnitTests/MinimalApiMediatRGeneratorSpec.cs @@ -75,6 +75,7 @@ public class AServiceClass : IWebApiService """ // using System; + using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Builder; using Infrastructure.Web.Api.Common.Extensions; @@ -125,6 +126,7 @@ public string AMethod(ARequest request) """ // using System; + using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Builder; using Infrastructure.Web.Api.Interfaces; @@ -139,9 +141,9 @@ public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebA var aserviceclassGroup = app.MapGroup(string.Empty) .WithGroupName("AServiceClass") .RequireCors("__DefaultCorsPolicy") - .AddEndpointFilter() - .AddEndpointFilter() - .AddEndpointFilter(); + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter(); aserviceclassGroup.MapGet("aroute", async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => await mediator.Send(request, global::System.Threading.CancellationToken.None)); @@ -200,6 +202,7 @@ public async Task AMethod(ARequest request) """ // using System; + using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Builder; using Infrastructure.Web.Api.Interfaces; @@ -214,9 +217,9 @@ public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebA var aserviceclassGroup = app.MapGroup(string.Empty) .WithGroupName("AServiceClass") .RequireCors("__DefaultCorsPolicy") - .AddEndpointFilter() - .AddEndpointFilter() - .AddEndpointFilter(); + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter(); aserviceclassGroup.MapGet("aroute", async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => await mediator.Send(request, global::System.Threading.CancellationToken.None)); @@ -276,6 +279,7 @@ public async Task AMethod(ARequest request, CancellationToken cancellati // using System.Threading; using System; + using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Builder; using Infrastructure.Web.Api.Interfaces; @@ -290,9 +294,9 @@ public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebA var aserviceclassGroup = app.MapGroup(string.Empty) .WithGroupName("AServiceClass") .RequireCors("__DefaultCorsPolicy") - .AddEndpointFilter() - .AddEndpointFilter() - .AddEndpointFilter(); + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter(); aserviceclassGroup.MapGet("aroute", async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => await mediator.Send(request, global::System.Threading.CancellationToken.None)); @@ -352,6 +356,7 @@ public async Task AMethod(ARequest request, CancellationToken cancellati // using System.Threading; using System; + using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Builder; using Infrastructure.Web.Api.Interfaces; @@ -366,9 +371,9 @@ public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebA var aserviceclassGroup = app.MapGroup(string.Empty) .WithGroupName("AServiceClass") .RequireCors("__DefaultCorsPolicy") - .AddEndpointFilter() - .AddEndpointFilter() - .AddEndpointFilter(); + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter(); #if TESTINGONLY aserviceclassGroup.MapGet("aroute", async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => @@ -432,6 +437,7 @@ public async Task AMethod(ARequest request, CancellationToken cancellati // using System.Threading; using System; + using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Builder; using Infrastructure.Web.Api.Interfaces; @@ -446,9 +452,9 @@ public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebA var aserviceclassGroup = app.MapGroup(string.Empty) .WithGroupName("AServiceClass") .RequireCors("__DefaultCorsPolicy") - .AddEndpointFilter() - .AddEndpointFilter() - .AddEndpointFilter(); + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter(); #if TESTINGONLY aserviceclassGroup.MapGet("aroute", async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => @@ -513,6 +519,7 @@ public async Task AMethod(ARequest request, CancellationToken cancellati // using System.Threading; using System; + using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Builder; using Infrastructure.Web.Api.Interfaces; @@ -527,9 +534,9 @@ public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebA var aserviceclassGroup = app.MapGroup(string.Empty) .WithGroupName("AServiceClass") .RequireCors("__DefaultCorsPolicy") - .AddEndpointFilter() - .AddEndpointFilter() - .AddEndpointFilter(); + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter(); #if TESTINGONLY aserviceclassGroup.MapGet("aroute", async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => @@ -603,6 +610,7 @@ public async Task AMethod(ARequest request, CancellationToken cancellati // using System.Threading; using System; + using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Builder; using Infrastructure.Web.Api.Interfaces; @@ -618,9 +626,9 @@ public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebA var aserviceclassGroup = app.MapGroup(string.Empty) .WithGroupName("AServiceClass") .RequireCors("__DefaultCorsPolicy") - .AddEndpointFilter() - .AddEndpointFilter() - .AddEndpointFilter(); + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter(); aserviceclassGroup.MapGet("aroute", async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => await mediator.Send(request, global::System.Threading.CancellationToken.None)); @@ -633,16 +641,18 @@ namespace ANamespace.AServiceClassMediatRHandlers { public class AMethod_ARequest_Handler : global::MediatR.IRequestHandler { - private readonly global::.ICallerContextFactory _contextFactory; + private readonly global::System.IServiceProvider _serviceProvider; - public AMethod_ARequest_Handler(global::.ICallerContextFactory contextFactory) + public AMethod_ARequest_Handler(global::System.IServiceProvider serviceProvider) { - this._contextFactory = contextFactory; + _serviceProvider = serviceProvider; } public async Task Handle(global::ANamespace.ARequest request, global::System.Threading.CancellationToken cancellationToken) { - var api = new global::ANamespace.AServiceClass(this._contextFactory); + var contextFactory = _serviceProvider.GetRequiredService<.ICallerContextFactory>(); + + var api = new global::ANamespace.AServiceClass(contextFactory); var result = await api.AMethod(request, cancellationToken); return result.HandleApiResult(global::Infrastructure.Web.Api.Interfaces.ServiceOperation.Get); } @@ -655,7 +665,7 @@ public AMethod_ARequest_Handler(global::.ICallerContextFactory } [Fact] - public void WhenDefinesAMethodWithMultipleAuthorizeAttributes_ThenGenerates() + public void WhenDefinesARequestWithMultipleAuthorizeAttributes_ThenGenerates() { var compilation = CreateCompilation(""" using System; @@ -689,6 +699,7 @@ public async Task AMethod(ARequest request, CancellationToken cancellati // using System.Threading; using System; + using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Builder; using Infrastructure.Web.Api.Interfaces; @@ -703,9 +714,9 @@ public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebA var aserviceclassGroup = app.MapGroup(string.Empty) .WithGroupName("AServiceClass") .RequireCors("__DefaultCorsPolicy") - .AddEndpointFilter() - .AddEndpointFilter() - .AddEndpointFilter(); + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter(); #if TESTINGONLY aserviceclassGroup.MapGet("aroute", async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => @@ -739,7 +750,7 @@ public class AMethod_ARequest_Handler : global::MediatR.IRequestHandler AMethod(ARequest request, CancellationToken cancellati // using System.Threading; using System; + using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Builder; using Infrastructure.Web.Api.Interfaces; @@ -786,9 +798,9 @@ public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebA var aserviceclassGroup = app.MapGroup(string.Empty) .WithGroupName("AServiceClass") .RequireCors("__DefaultCorsPolicy") - .AddEndpointFilter() - .AddEndpointFilter() - .AddEndpointFilter(); + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter(); #if TESTINGONLY aserviceclassGroup.MapGet("aroute", async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => @@ -821,6 +833,88 @@ public class AMethod_ARequest_Handler : global::MediatR.IRequestHandler, ITenantedRequest + { + public string? OrganizationId { get; set; } + } + public class AServiceClass : IWebApiService + { + public async Task AMethod(ARequest request, CancellationToken cancellationToken) + { + return ""; + } + } + """); + + var result = Generate(compilation); + + result.Should().Be( + """ + // + using System.Threading; + using System; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Builder; + using Infrastructure.Web.Api.Interfaces; + using Infrastructure.Web.Api.Common.Extensions; + + namespace compilation + { + public static class MinimalApiRegistration + { + public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebApplication app) + { + var aserviceclassGroup = app.MapGroup(string.Empty) + .WithGroupName("AServiceClass") + .RequireCors("__DefaultCorsPolicy") + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter(); + aserviceclassGroup.MapGet("aroute", + async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => + await mediator.Send(request, global::System.Threading.CancellationToken.None)) + .RequireAuthorization("Token") + .RequireCallerAuthorization("POLICY:{|Features|:{|Platform|:[|basic_features|]},|Roles|:{|Platform|:[|standard|]}}"); + + } + } + } + + namespace ANamespace.AServiceClassMediatRHandlers + { + public class AMethod_ARequest_Handler : global::MediatR.IRequestHandler + { + public async Task Handle(global::ANamespace.ARequest request, global::System.Threading.CancellationToken cancellationToken) + { + var api = new global::ANamespace.AServiceClass(); + var result = await api.AMethod(request, cancellationToken); + return result.HandleApiResult(global::Infrastructure.Web.Api.Interfaces.ServiceOperation.Get); + } + } + + } + + + """); + } + [Fact] public void WhenDefinesAWebServiceAttribute_ThenGenerates() { @@ -856,6 +950,7 @@ public async Task AMethod(ARequest request, CancellationToken cancellati // using System.Threading; using System; + using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Builder; using Infrastructure.Web.Api.Interfaces; @@ -870,9 +965,9 @@ public static void RegisterRoutes(this global::Microsoft.AspNetCore.Builder.WebA var aserviceclassGroup = app.MapGroup("aprefix") .WithGroupName("AServiceClass") .RequireCors("__DefaultCorsPolicy") - .AddEndpointFilter() - .AddEndpointFilter() - .AddEndpointFilter(); + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter(); #if TESTINGONLY aserviceclassGroup.MapGet("aroute", async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::ANamespace.ARequest request) => diff --git a/src/Tools.Generators.Web.Api.UnitTests/WebApiAssemblyVisitorSpec.cs b/src/Tools.Generators.Web.Api.UnitTests/WebApiAssemblyVisitorSpec.cs index 96c76d38..5d2a570f 100644 --- a/src/Tools.Generators.Web.Api.UnitTests/WebApiAssemblyVisitorSpec.cs +++ b/src/Tools.Generators.Web.Api.UnitTests/WebApiAssemblyVisitorSpec.cs @@ -413,10 +413,10 @@ public string AMethod(ARequest request) registration.OperationAuthorization.Should().BeNull(); registration.RoutePath.Should().Be("aroute"); registration.IsTestingOnly.Should().BeFalse(); - registration.RequestDtoType.Name.Should().Be("ARequest"); - registration.RequestDtoType.Namespace.Should().Be("ANamespace"); - registration.ResponseDtoType.Name.Should().Be("AResponse"); - registration.ResponseDtoType.Namespace.Should().Be("ANamespace"); + registration.RequestDtoName.Name.Should().Be("ARequest"); + registration.RequestDtoName.Namespace.Should().Be("ANamespace"); + registration.ResponseDtoName.Name.Should().Be("AResponse"); + registration.ResponseDtoName.Namespace.Should().Be("ANamespace"); } [Fact] @@ -468,10 +468,10 @@ public string AMethod(ARequest request) registration.OperationAuthorization!.PolicyName.Should().Be("POLICY:{|Features|:{|Platform|:[|basic_features|]},|Roles|:{|Platform|:[|standard|]}}"); registration.RoutePath.Should().Be("aroute"); registration.IsTestingOnly.Should().BeFalse(); - registration.RequestDtoType.Name.Should().Be("ARequest"); - registration.RequestDtoType.Namespace.Should().Be("ANamespace"); - registration.ResponseDtoType.Name.Should().Be("AResponse"); - registration.ResponseDtoType.Namespace.Should().Be("ANamespace"); + registration.RequestDtoName.Name.Should().Be("ARequest"); + registration.RequestDtoName.Namespace.Should().Be("ANamespace"); + registration.ResponseDtoName.Name.Should().Be("AResponse"); + registration.ResponseDtoName.Namespace.Should().Be("ANamespace"); } [Fact] @@ -526,10 +526,10 @@ public string AMethod(ARequest request) + "POLICY:{|Features|:{|Platform|:[|basic_features|]},|Roles|:{|Platform|:[|standard|]}}"); registration.RoutePath.Should().Be("aroute"); registration.IsTestingOnly.Should().BeFalse(); - registration.RequestDtoType.Name.Should().Be("ARequest"); - registration.RequestDtoType.Namespace.Should().Be("ANamespace"); - registration.ResponseDtoType.Name.Should().Be("AResponse"); - registration.ResponseDtoType.Namespace.Should().Be("ANamespace"); + registration.RequestDtoName.Name.Should().Be("ARequest"); + registration.RequestDtoName.Namespace.Should().Be("ANamespace"); + registration.ResponseDtoName.Name.Should().Be("AResponse"); + registration.ResponseDtoName.Namespace.Should().Be("ANamespace"); } [Fact] diff --git a/src/Tools.Generators.Web.Api/MinimalApiMediatRGenerator.cs b/src/Tools.Generators.Web.Api/MinimalApiMediatRGenerator.cs index 23465d74..35fa7773 100644 --- a/src/Tools.Generators.Web.Api/MinimalApiMediatRGenerator.cs +++ b/src/Tools.Generators.Web.Api/MinimalApiMediatRGenerator.cs @@ -19,10 +19,11 @@ public class MinimalApiMediatRGenerator : ISourceGenerator private const string RegistrationClassName = "MinimalApiRegistration"; private const string TestingOnlyDirective = "TESTINGONLY"; + // ReSharper disable once UseCollectionExpression private static readonly string[] RequiredUsingNamespaces = { "System", "Microsoft.AspNetCore.Builder", "Microsoft.AspNetCore.Http", - "Infrastructure.Web.Api.Common.Extensions" + "Microsoft.Extensions.DependencyInjection", "Infrastructure.Web.Api.Common.Extensions" }; public void Initialize(GeneratorInitializationContext context) @@ -102,12 +103,11 @@ private static void BuildEndpointRegistrations( var prefix = basePath.HasValue() ? $"\"{basePath}\"" : "string.Empty"; + + var endpointFilters = BuildEndpointFilters(serviceRegistrations); endpointRegistrations.AppendLine($@" var {groupName} = app.MapGroup({prefix}) .WithGroupName(""{serviceClassName}"") - .RequireCors(""{WebHostingConstants.DefaultCORSPolicyName}"") - .AddEndpointFilter() - .AddEndpointFilter() - .AddEndpointFilter();"); + .RequireCors(""{WebHostingConstants.DefaultCORSPolicyName}""){endpointFilters};"); foreach (var registration in serviceRegistrations) { @@ -126,12 +126,12 @@ private static void BuildEndpointRegistrations( || registration.OperationType == ServiceOperation.Delete) { endpointRegistrations.AppendLine( - $" async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::{registration.RequestDtoType.FullName} request) =>"); + $" async (global::MediatR.IMediator mediator, [global::Microsoft.AspNetCore.Http.AsParameters] global::{registration.RequestDtoName.FullName} request) =>"); } else { endpointRegistrations.AppendLine( - $" async (global::MediatR.IMediator mediator, global::{registration.RequestDtoType.FullName} request) =>"); + $" async (global::MediatR.IMediator mediator, global::{registration.RequestDtoName.FullName} request) =>"); } endpointRegistrations.Append( @@ -170,6 +170,45 @@ private static void BuildEndpointRegistrations( } } + private static string BuildEndpointFilters( + IGrouping + serviceRegistrations) + { + var filterSet = new List + { + "global::Infrastructure.Web.Api.Common.Endpoints.ApiUsageFilter", + "global::Infrastructure.Web.Api.Common.Endpoints.RequestCorrelationFilter", + "global::Infrastructure.Web.Api.Common.Endpoints.ContentNegotiationFilter" + }; + var isMultiTenanted = serviceRegistrations.Any(registration => registration.IsRequestDtoTenanted); + if (isMultiTenanted) + { + filterSet.Insert(0, "global::Infrastructure.Web.Api.Common.Endpoints.MultiTenancyFilter"); + } + + var builder = new StringBuilder(); + var counter = filterSet.Count; + if (filterSet.HasAny()) + { + builder.AppendLine(); + filterSet.ForEach(filter => + { + counter--; + var value = $" .AddEndpointFilter<{filter}>()"; + if (counter == 0) + { + builder.Append(value); + } + else + { + builder.AppendLine(value); + } + }); + } + + return builder.ToString(); + } + private static void BuildHandlerClasses( IGrouping serviceRegistrations, StringBuilder handlerClasses) @@ -180,7 +219,7 @@ private static void BuildHandlerClasses( foreach (var registration in serviceRegistrations) { - var handlerClassName = $"{registration.MethodName}_{registration.RequestDtoType.Name}_Handler"; + var handlerClassName = $"{registration.MethodName}_{registration.RequestDtoName.Name}_Handler"; var constructorAndFields = BuildInjectorConstructorAndFields(handlerClassName, registration.Class.Constructors.ToList()); @@ -190,7 +229,7 @@ private static void BuildHandlerClasses( } handlerClasses.AppendLine( - $" public class {handlerClassName} : global::MediatR.IRequestHandler"); handlerClasses.AppendLine(" {"); if (constructorAndFields.HasValue()) @@ -199,7 +238,7 @@ private static void BuildHandlerClasses( } handlerClasses.AppendLine($" public async Task" - + $" Handle(global::{registration.RequestDtoType.FullName} request, global::System.Threading.CancellationToken cancellationToken)"); + + $" Handle(global::{registration.RequestDtoName.FullName} request, global::System.Threading.CancellationToken cancellationToken)"); handlerClasses.AppendLine(" {"); if (!registration.IsAsync) { @@ -207,7 +246,21 @@ private static void BuildHandlerClasses( " await Task.CompletedTask;"); } - var callingParameters = BuildInjectedParameters(registration.Class.Constructors.ToList()); + var callingParameters = string.Empty; + var injectorCtor = registration.Class.Constructors.FirstOrDefault(ctor => ctor.IsInjectionCtor); + if (injectorCtor is not null) + { + var parameters = injectorCtor.CtorParameters.ToList(); + foreach (var param in parameters) + { + handlerClasses.AppendLine( + $" var {param.VariableName} = _serviceProvider.GetRequiredService<{param.TypeName.FullName}>();"); + } + + handlerClasses.AppendLine(); + callingParameters = BuildInjectedParameters(registration.Class.Constructors.ToList()); + } + handlerClasses.AppendLine( $" var api = new global::{registration.Class.TypeName.FullName}({callingParameters});"); var asyncAwait = registration.IsAsync @@ -242,33 +295,13 @@ private static string BuildInjectorConstructorAndFields(string handlerClassName, var injectorCtor = constructors.FirstOrDefault(ctor => ctor.IsInjectionCtor); if (injectorCtor is not null) { - var parameters = injectorCtor.CtorParameters.ToList(); - foreach (var param in parameters) - { - handlerClassConstructorAndFields.AppendLine( - $" private readonly global::{param.TypeName.FullName} _{param.VariableName};"); - } - + handlerClassConstructorAndFields.AppendLine( + " private readonly global::System.IServiceProvider _serviceProvider;"); handlerClassConstructorAndFields.AppendLine(); - handlerClassConstructorAndFields.Append($" public {handlerClassName}("); - var paramsRemaining = parameters.Count(); - foreach (var param in parameters) - { - handlerClassConstructorAndFields.Append($"global::{param.TypeName.FullName} {param.VariableName}"); - if (--paramsRemaining > 0) - { - handlerClassConstructorAndFields.Append(", "); - } - } - - handlerClassConstructorAndFields.AppendLine(")"); + handlerClassConstructorAndFields.AppendLine( + $" public {handlerClassName}(global::System.IServiceProvider serviceProvider)"); handlerClassConstructorAndFields.AppendLine(" {"); - foreach (var param in parameters) - { - handlerClassConstructorAndFields.AppendLine( - $" this._{param.VariableName} = {param.VariableName};"); - } - + handlerClassConstructorAndFields.AppendLine(" _serviceProvider = serviceProvider;"); handlerClassConstructorAndFields.AppendLine(" }"); } @@ -287,7 +320,7 @@ private static string BuildInjectedParameters(List 0) { methodParameters.Append(", "); diff --git a/src/Tools.Generators.Web.Api/Tools.Generators.Web.Api.csproj b/src/Tools.Generators.Web.Api/Tools.Generators.Web.Api.csproj index d7d0ac87..cbb3a3e2 100644 --- a/src/Tools.Generators.Web.Api/Tools.Generators.Web.Api.csproj +++ b/src/Tools.Generators.Web.Api/Tools.Generators.Web.Api.csproj @@ -40,6 +40,9 @@ Reference\Infrastructure.Web.Api.Interfaces\IWebResponse.cs + + Reference\Infrastructure.Web.Api.Interfaces\ITenantedRequest.cs + Reference\Infrastructure.Web.Api.Interfaces\WebServiceAttribute.cs diff --git a/src/Tools.Generators.Web.Api/WebApiAssemblyVisitor.cs b/src/Tools.Generators.Web.Api/WebApiAssemblyVisitor.cs index da142172..d179d8ff 100644 --- a/src/Tools.Generators.Web.Api/WebApiAssemblyVisitor.cs +++ b/src/Tools.Generators.Web.Api/WebApiAssemblyVisitor.cs @@ -34,6 +34,7 @@ public class WebApiAssemblyVisitor : SymbolVisitor private readonly INamedTypeSymbol _serviceInterfaceSymbol; private readonly INamedTypeSymbol _voidSymbol; private readonly INamedTypeSymbol _webRequestInterfaceSymbol; + private readonly INamedTypeSymbol _tenantedWebRequestInterfaceSymbol; private readonly INamedTypeSymbol _webRequestResponseInterfaceSymbol; private readonly INamedTypeSymbol _webserviceAttributeSymbol; @@ -42,6 +43,7 @@ public WebApiAssemblyVisitor(CancellationToken cancellationToken, Compilation co _cancellationToken = cancellationToken; _serviceInterfaceSymbol = compilation.GetTypeByMetadataName(typeof(IWebApiService).FullName!)!; _webRequestInterfaceSymbol = compilation.GetTypeByMetadataName(typeof(IWebRequest).FullName!)!; + _tenantedWebRequestInterfaceSymbol = compilation.GetTypeByMetadataName(typeof(ITenantedRequest).FullName!)!; _webRequestResponseInterfaceSymbol = compilation.GetTypeByMetadataName(typeof(IWebRequest<>).FullName!)!; _webserviceAttributeSymbol = compilation.GetTypeByMetadataName(typeof(WebServiceAttribute).FullName!)!; _routeAttributeSymbol = compilation.GetTypeByMetadataName(typeof(RouteAttribute).FullName!)!; @@ -189,6 +191,7 @@ private void AddRegistration(INamedTypeSymbol symbol) var requestType = method.Parameters[0].Type; var requestTypeName = requestType.Name; var requestTypeNamespace = requestType.ContainingNamespace.ToDisplayString(); + var requestTypeIsMultiTenanted = requestType.IsDerivedFrom(_tenantedWebRequestInterfaceSymbol); var responseType = GetResponseType(method.Parameters[0].Type); var responseTypeName = responseType.Name; var responseTypeNamespace = responseType.ContainingNamespace.ToDisplayString(); @@ -200,8 +203,9 @@ private void AddRegistration(INamedTypeSymbol symbol) OperationRegistrations.Add(new ServiceOperationRegistration { Class = classRegistration, - RequestDtoType = new TypeName(requestTypeNamespace, requestTypeName), - ResponseDtoType = new TypeName(responseTypeNamespace, responseTypeName), + RequestDtoName = new TypeName(requestTypeNamespace, requestTypeName), + IsRequestDtoTenanted = requestTypeIsMultiTenanted, + ResponseDtoName = new TypeName(responseTypeNamespace, responseTypeName), OperationType = operationType, OperationAccess = operationAccess, OperationAuthorization = operationAuthorization, @@ -473,9 +477,11 @@ public record ServiceOperationRegistration public ServiceOperation OperationType { get; set; } - public TypeName RequestDtoType { get; set; } = null!; + public TypeName RequestDtoName { get; set; } = null!; - public TypeName ResponseDtoType { get; set; } = null!; + public bool IsRequestDtoTenanted { get; set; } + + public TypeName ResponseDtoName { get; set; } = null!; public string? RoutePath { get; set; } } diff --git a/src/Tools.Templates/IntegrationTestProject/Api1Spec.cs b/src/Tools.Templates/IntegrationTestProject/Api1Spec.cs index 59b34274..c2aada8e 100644 --- a/src/Tools.Templates/IntegrationTestProject/Api1Spec.cs +++ b/src/Tools.Templates/IntegrationTestProject/Api1Spec.cs @@ -11,7 +11,7 @@ public class Api1Spec : WebApiSpec { public Api1Spec(WebApiSetup setup) : base(setup, OverrideDependencies) { - EmptyAllRepositories(setup); + EmptyAllRepositories(); } private static void OverrideDependencies(IServiceCollection services)